diff --git a/.gitattributes b/.gitattributes index 145674cc..02a9f097 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,4 +23,5 @@ *.ttf binary *.pdf binary *.xcf binary -*.ico binary \ No newline at end of file +*.ico binary +*.mp4 binary diff --git a/com.unity.mobile.android-logcat/CHANGELOG.md b/com.unity.mobile.android-logcat/CHANGELOG.md index 43a8ab23..cd3712c1 100644 --- a/com.unity.mobile.android-logcat/CHANGELOG.md +++ b/com.unity.mobile.android-logcat/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - The details beside a selected screenshot show the device, its Android version and display size, along with the image size, the file size and when it was captured. The device details read `Undefined` for a screenshot saved without them. - The screenshot and the live view can be zoomed with Ctrl+Wheel (Cmd+Wheel on macOS), and the zoomed image moved with a Ctrl+middle mouse button drag or with the scrollbars that appear. - The device screen can be viewed live from the Screen Capture window: select the "Live" row at the top of the screenshot list. Clicks, drags, the scroll wheel and typing are sent to the device as touch, scroll and key events, and Ctrl+A, Ctrl+C and Ctrl+V (Cmd on macOS) select all, copy and paste on the device using its own clipboard, and Back / Home / Overview buttons sit beside the image with the stream details. + - Screen recordings are now made from the live view, with **Take Recording** in the live view, instead of with `adb screenrecord`. They are saved as constant frame rate MP4 files beside the screenshots, at the live stream's **Max Frame Rate** (up to 60 fps), and follow the live view's rotation. A recording stops on its own after the **Time Limit** set in Preferences (10 minutes by default, up to 60). Recording is not available on Linux. + - Recordings can be played in the Screen Capture window, with play/pause, a time slider and buttons that step one frame back or forward. - Tools->Open Terminal is now supported on Linux Editor. - Fixed Tools->Open Terminal not opening a window on Windows Editor when running on CoreCLR. diff --git a/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md b/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md index 7d331960..288073cc 100644 --- a/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md +++ b/com.unity.mobile.android-logcat/Documentation~/android-logcat-settings.md @@ -8,6 +8,7 @@ To open the Android Logcat Settings window in the Unity Editor, go to **Edit** > * [Memory Window](#memory-window) * [Packages](#packages) * [Capture Settings](#capture-settings) + * [Recording](#recording) * [Live Stream](#live-stream) * [Stacktrace Regex](#stacktrace-regex) * [Symbol Extensions](#symbol-extensions) @@ -64,15 +65,23 @@ The default, `AndroidLogcat/Captures`, is a folder in your project, beside `Asse Changing the folder takes effect immediately. Unity doesn't move existing captures to the new folder: they stay in the old folder, and the [Capture list](screen-capture-window-reference.md#capture-list) displays only the captures in the new folder. To keep using earlier captures, move them to the new folder yourself, together with their `.json` files. +### Recording + +|**Setting**|**Description**| +|---|---| +|**Time Limit (minutes)**|Specifies how long a recording runs before it stops on its own, between 1 and 60 minutes. The default value is 10.| +|**Square Video**|Records a square video, as wide and tall as the longest side of the stream, so turning the device or unfolding a foldable keeps the picture at full size. When disabled, the video is the size of the screen when recording starts, and a turned or unfolded screen is scaled down to fit. Enabled by default.| +|**Record Touches**|Saves the clicks and drags you send from the live view with a recording. When the recording plays in the Screen Capture window, a marker shows where the screen was touched: green when pressed, blue while dragging, and white, fading, once released, or red if the touch was cancelled. Other video players don't show the markers. Enabled by default.| + ### Live Stream -Use the **Live Stream** settings to control the [live view of the device's screen](screen-capture-live-stream.md). Streaming a display uses the device's CPU to compress each frame and the connection to your computer to carry it, so these settings trade image quality for both. +Use the **Live Stream** settings to control the [live view of the device's screen](screen-capture-live-stream.md). Recordings are made from the live view, so these settings also set the size and quality of a recording. Streaming a display uses the device's CPU to compress each frame and the connection to your computer to carry it, so these settings trade image quality for both. |**Setting**|**Description**| |---|---| |**Max Size**|Specifies the longest side of the streamed image in pixels, between 256 and 2048. The device's display is scaled down to fit. The default value is 1024.| |**JPEG Quality**|Specifies the quality of each streamed frame, between 1 and 100. Lower values produce smaller frames and use less bandwidth. The default value is 70.| -|**Max Frame Rate**|Specifies the highest number of frames per second the device sends, between 1 and 120. The device only sends a frame when its screen changes, so this is a limit rather than a rate. The default value is 30.| +|**Max Frame Rate**|Specifies the highest number of frames per second the device sends, between 1 and 60, and the frame rate recordings are made at. The device only sends a frame when its screen changes, so this is a limit rather than a rate. The default value is 30.| These settings apply when a stream starts. To apply them to a stream that is already running, turn **Live** off and on again in the [Device Screen Capture window](screen-capture-window-reference.md#capture-list). diff --git a/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png b/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png index 0ded6006..21964c85 100644 Binary files a/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png and b/com.unity.mobile.android-logcat/Documentation~/images/capture_screenshot.png differ diff --git a/com.unity.mobile.android-logcat/Documentation~/images/capture_video.png b/com.unity.mobile.android-logcat/Documentation~/images/capture_video.png index 4e2028b3..182ec8f8 100644 Binary files a/com.unity.mobile.android-logcat/Documentation~/images/capture_video.png and b/com.unity.mobile.android-logcat/Documentation~/images/capture_video.png differ diff --git a/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-live-view.png b/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-live-view.png index a39745da..f35dce4c 100644 Binary files a/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-live-view.png and b/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-live-view.png differ diff --git a/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-window-toolbar.png b/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-window-toolbar.png deleted file mode 100644 index 5eb4f18c..00000000 Binary files a/com.unity.mobile.android-logcat/Documentation~/images/device-screen-capture-window-toolbar.png and /dev/null differ diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md index 94c140be..6c821a29 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-screenshot.md @@ -4,13 +4,12 @@ This page explains how to use the [Screen Capture tool](screen-capture.md) to ca 1. Open the [Device Screen Capture window](screen-capture-window-reference.md). 2. In the [Toolbar](screen-capture-window-reference.md#toolbar), use **Device Selector** to specify the device to take a screenshot of. -3. Set **Screen Capture Mode** to **Screenshot**. -4. In the [Capture list](screen-capture-window-reference.md#capture-list), select **Live**, then select **Take Screenshot** in the [Live view details](screen-capture-window-reference.md#live-view-details). Alternatively, press **Ctrl+Shift+S** (macOS: **Cmd+Shift+S**) while the live view displays. The Screen Capture tool takes a screenshot of the connected device, displays it in the [Capture preview](screen-capture-window-reference.md#capture-preview), and adds it to the list. -5. To save a copy of the screenshot elsewhere on your computer, right-click its row in the Capture list, select **Copy To...**, and use the file explorer to choose the location. To copy several screenshots into one folder, select them first and then right-click. +3. In the [Capture list](screen-capture-window-reference.md#capture-list), select **Live**, then select **Take Screenshot** in the [Live view details](screen-capture-window-reference.md#live-view-details). Alternatively, press **Ctrl+Shift+S** (macOS: **Cmd+Shift+S**) while the live view displays. The Screen Capture tool takes a screenshot of the connected device, displays it in the [Capture preview](screen-capture-window-reference.md#capture-preview), and adds it to the list. +4. To save a copy of the screenshot elsewhere on your computer, right-click its row in the Capture list, select **Copy To...**, and use the file explorer to choose the location. To copy several screenshots into one folder, select them first and then right-click. Every screenshot you capture is kept, so you don't have to save one before taking the next. Screenshots are named `_.png` and are stored in your project, in `AndroidLogcat/Captures`, unless you set [Captures Folder](android-logcat-settings.md#capture-settings) to another folder. The default folder is outside `Assets` and isn't part of your build, so use **Copy To...**, or a captures folder of your own, for a screenshot you want to share. -Each screenshot is saved with a `.json` file of the same name beside it. The `.json` file records the device the screenshot came from and when it was captured. The device information includes the device name, ID, Android version, API level, ABI, and display size. The [Screenshot details](screen-capture-window-reference.md#screenshot-details) beside the image display this information. When you rename, delete, or copy a screenshot, the Screen Capture tool renames, deletes, or copies its `.json` file too, so a copy you save elsewhere still records the device it came from. A screenshot without a `.json` file still opens, and its device details display `Undefined`. +Each screenshot is saved with a `.json` file of the same name beside it. The `.json` file records the device the screenshot came from and when it was captured. The device information includes the device name, ID, Android version, API level, ABI, and display size. The [Capture details](screen-capture-window-reference.md#capture-details) beside the image display this information. When you rename, delete, or copy a screenshot, the Screen Capture tool renames, deletes, or copies its `.json` file too, so a copy you save elsewhere still records the device it came from. A screenshot without a `.json` file still opens, and its device details display `Undefined`. To rename or delete a screenshot, or to show it in Explorer or Finder, use the [Capture list](screen-capture-window-reference.md#capture-list). To look at part of a screenshot more closely, [zoom into it](screen-capture-window-reference.md#zoom-into-the-image). diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-video.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-video.md index aaf351d6..81428c74 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture-video.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-video.md @@ -1,22 +1,26 @@ # Capture a video -This page explains how to use the [Screen Capture tool](screen-capture.md) to capture a video of the connected device's screen and save it as a file on your computer. +This page explains how to use the [Screen Capture tool](screen-capture.md) to record the connected device's screen. 1. Open the [Device Screen Capture window](screen-capture-window-reference.md). -2. In the [Toolbar](screen-capture-window-reference.md#toolbar), use **Device Selector** to specify to device to take a screenshot of. -3. Set **Screen Capture Mode** to **Video**. -4. Select **Capture**. The Screen Capture tool begins to capture a video of the connected device's screen. -5. When you want to finish the video, select **Stop**. The Screen Capture tool finishes capturing the video and displays it in the [Capture preview](screen-capture-window-reference.md#capture-preview). -6. Select **Save As** and use the file explorer to save the video file to your computer. +2. In the [Toolbar](screen-capture-window-reference.md#toolbar), use **Device Selector** to specify the device to record. +3. Select **Live** in the [Capture list](screen-capture-window-reference.md#capture-list) to view the device's screen. +4. Select **Take Recording** in the [Live view details](screen-capture-window-reference.md#live-view-details). The Screen Capture tool begins to record the device's screen, and the button becomes **Stop Recording**. +5. Select **Stop Recording** to finish. The recording is added to the [Capture list](screen-capture-window-reference.md#capture-list), and its row is briefly highlighted. Select the row to open it in the [Capture preview](screen-capture-window-reference.md#capture-preview). The Capture list is disabled while recording. Closing the window also stops the recording. +6. To keep a copy elsewhere, right-click the recording's row and select **Copy To...**. + +Recordings are stored beside screenshots, in the folder set by [Captures Folder](android-logcat-settings.md#capture-settings), and are named `_.mp4`. + +A recording is made from the live view, so it shows the view as it is turned. The video is square, as wide and tall as the longest side of the stream, so turning the device, or unfolding a foldable, keeps the picture at full size. The rest of the square is black. To record at the size of the screen instead, disable **Square Video** in the [Recording settings](android-logcat-settings.md#recording). To record at a larger size or a higher quality, raise **Max Size** and **JPEG Quality** in the [Live Stream settings](android-logcat-settings.md#live-stream). Recordings play at the **Max Frame Rate** of the Live Stream settings, 30 frames per second by default. A higher rate records fast movement more smoothly, but costs more Editor time, and at a large **Max Size** the device may not deliver frames that fast. The device only sends a frame when its screen changes, so while the screen is still, the recording repeats the last frame. A recording stops on its own after the **Time Limit** in the [Recording settings](android-logcat-settings.md#recording), 10 minutes by default. It also stops, and is saved, when Unity reloads scripts, for example after a script is changed. ## Details * When recording a video, the Screen Recorder Tool doesn't capture sound. -* The Screen Recorder Tool uses Unity's video player for the video preview. If your operating system is Windows, you might see `WindowsVideoMedia` warnings in the Editor console. This is because the video is not processed. -* If the entire video contains a static image, the video only contains one frame and the length of the video will be zero. +* Recording uses the Editor to encode the video, which takes some of its time while a recording runs. A larger stream costs more. +* The Linux Editor can't record, or play recordings back, because it doesn't support H.264 video. * On Chrome OS, you can only capture the video from the sandboxed app which is the one you install manually via adb or Unity. You can't capture video from the desktop or from built-in apps. ## Additional resources * [Device Screen Capture window reference](screen-capture-window-reference.md) -* [Capture a screenshot](screen-capture-screenshot.md) \ No newline at end of file +* [Capture a screenshot](screen-capture-screenshot.md) diff --git a/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md b/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md index 74882bf6..6afc12a6 100644 --- a/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md +++ b/com.unity.mobile.android-logcat/Documentation~/screen-capture-window-reference.md @@ -9,15 +9,14 @@ You can also open it from the Android Logcat window: 1. Open the [Android Logcat window](android-logcat-window.md). 2. From the [toolbar](android-logcat-window-reference.md#toolbar), select **Tools** > **Screen Capture**. -The Device Screen Capture window contains the following areas. Some areas appear only in a specific **Screen Capture Mode**. +The Device Screen Capture window contains the following areas. | **Area** | **Description** | | --------------------------------------- | ------------------------------------------------------------ | | [Toolbar](#toolbar) | Contains options for the Device Screen Capture window. | -| [Capture list](#capture-list) | The live view and every screenshot you have taken. Appears only in screenshot mode. | -| [Recorder settings](#recorder-settings) | Contains settings for video recording. Appears only in video mode. | -| [Capture preview](#capture-preview) | The screenshot or live view that you select in the list, or the recorded video in video mode. | -| [Screenshot details](#screenshot-details) | Information about the selected screenshot. | +| [Capture list](#capture-list) | The live view and every capture you have taken. Drag the divider to resize it. | +| [Capture preview](#capture-preview) | The screenshot, recording, or live view that you select in the list. | +| [Capture details](#capture-details) | Information about the selected screenshot or recording. | | [Live view details](#live-view-details) | Information about the live stream, and the device controls. | | [Status bar](#status-bar) | Where the last screenshot or video was saved. | @@ -25,31 +24,21 @@ The Device Screen Capture window contains the following areas. Some areas appear The toolbar contains options to control the Screen Capture tool. -![The Device Screen Capture window toolbar](images/device-screen-capture-window-toolbar.png) -> The Device Screen Capture window toolbar. - | **Toolbar option** | **Description** | | ----------------------- | ------------------------------------------------------------ | | **Device Selector** | Specifies the Android device to capture the screen of. | -| **Screen Capture Mode** | Specifies the screen capture mode to use. The options are:
  • **Screenshot**: Switches the Screen Capture tool to screenshot mode to display the capture list and the capture preview. To take a screenshot, select **Take Screenshot** in the [Live view details](#live-view-details).
  • **Video**: Switches the Screen Capture tool to video mode to display the recorder settings and the video preview. Select **Capture** to start recording the selected device's screen. Select **Stop** to stop the recording and display the video in the [Capture preview](#capture-preview).
| -| **Capture** | Begins video recording.
This option appears only in video mode. To take a screenshot, select **Take Screenshot** in the [Live view details](#live-view-details), or press **Ctrl+Shift+S** (macOS: **Cmd+Shift+S**) while the live view displays. | -| **Stop** | Stops video recording.
This option appears only while the Screen Capture tool is recording a video. | -| **Open** | Opens the video using the application associated with the `.mp4` file extension.
This option appears only in video mode. To open a screenshot, right-click its row in the [Capture list](#capture-list). | -| **Save As** | Saves the video as a file on your computer.
This option appears only in video mode. To save a screenshot, right-click its row in the [Capture list](#capture-list). Its details file is saved with the copy. | +| **Advanced** | Opens a menu with **Preferences**, which contains the capture settings. | ## Capture list The capture list contains the **Live** button and every capture you have taken from every device. Select **Live** to mirror the device's screen, or select a capture to display it in the [Capture preview](#capture-preview). The **Up Arrow** and **Down Arrow** keys move through the captures. Drag the divider between the list and the preview to resize the list. -> [!NOTE] -> The capture list appears only if you set **Screen Capture Mode** to **Screenshot**. - | **Item** | **Description** | | ------------------ | ------------------------------------------------------------ | -| **Live** | Displays the selected device's screen live. Select **Live** to view the screen and take screenshots. For more information, refer to [View the device screen live](screen-capture-live-stream.md). | -| A capture | Named after its file, without the `.png` extension. Captures are saved as you take them, so every one stays until you delete it. | +| **Live** | Displays the selected device's screen live. Select **Live** to view the screen, take screenshots, and record videos. For more information, refer to [View the device screen live](screen-capture-live-stream.md). | +| A capture | A screenshot or a recording, named after its file without its extension, with an icon that shows which it is. Captures are saved as you take them, so every one stays until you delete it. Selecting a recording opens it in the [Capture preview](#capture-preview), paused on its first frame. | -Screenshots are stored in the folder set by [Captures Folder](android-logcat-settings.md#capture-settings), and are named `_.png`. The default folder, `AndroidLogcat/Captures`, is in your project but outside `Assets`, so it isn't part of your build. Deleting the `Library` folder doesn't delete captures. +Captures are stored in the folder set by [Captures Folder](android-logcat-settings.md#capture-settings), and are named `_` with a `.png` or `.mp4` extension. The default folder, `AndroidLogcat/Captures`, is in your project but outside `Assets`, so it isn't part of your build. Deleting the `Library` folder doesn't delete captures. The following table describes the actions you can perform in the capture list. @@ -59,7 +48,7 @@ The following table describes the actions you can perform in the capture list. | **Shift**+click a row | Extends the selection from the last row you clicked. | | **Ctrl**+click a row (macOS: **Cmd**+click) | Adds a capture to the selection, or removes it. | | Press **Ctrl+A** (macOS: **Cmd+A**) | Selects every capture. | -| Double-click a row | Opens the image in the application associated with `.png`. | +| Double-click a row | Opens the capture in the application associated with its file type. | | Press **Delete** (macOS: **Cmd+Backspace**) | Deletes every selected capture from disk, after asking you to confirm. | | Right-click a row | Opens a menu with **Show In Explorer** (macOS: **Show In Finder**), **Open**, **Copy To...**, **Rename**, **Delete**, and **Select All**. **Show In Explorer**, **Open**, and **Rename** act on the row you clicked and are unavailable while several captures are selected. **Copy To...** and **Delete** act on the whole selection: copying one capture asks for a name, copying several asks for a folder to put them in. | | Press **F2** (macOS: **Enter**) | Renames the selected capture. **Enter** confirms the new name and **Escape** cancels. | @@ -69,23 +58,9 @@ To empty the list, select every capture with **Ctrl+A** (macOS: **Cmd+A**) and p > [!NOTE] > Renaming a screenshot to something other than `_` keeps it in the list, but it no longer counts towards that device's numbering. -## Recorder settings - -Contains settings for video recording. The Screen Capture tool contains default values for each setting. To override the default value for a setting, toggle the setting and enter your own value. - -> [!NOTE] -> This section only appears if you set **Screen Capture Mode** to **Video**. - -| **Property** | **Description** | -| -------------- | ------------------------------------------------------------ | -| **Time Limit** | The time limit of the screen video recording in seconds. The default value is 180 seconds. | -| **Video Size** | The width and height of the video recording. The default value is the Android device's main display resolution. | -| **Bit Rate** | The bit rate of the video recording. The default bit rate is 20000000 bits per second. | -| **Display Id** | The ID of the display being recorded. The default display ID is the primary display. To get display ids, execute `adb shell dumpsys SurfaceFlinger --display-id` in the terminal. | - ## Capture preview -In screenshot mode, this section displays what you select in the [Capture list](#capture-list): a screenshot, or the live view of the device's screen. In video mode, it displays the recorded video. You can use this to check the quality of the screen capture before you save it as a file on your computer. +This section displays what you select in the [Capture list](#capture-list): a screenshot, a recording, or the live view of the device's screen. You can use this to check the quality of the screen capture before you save it as a file on your computer. ### Zoom into the image @@ -96,25 +71,30 @@ A screenshot and the live view are both fitted to the window, which can be too s | **Ctrl**+scroll (macOS: **Cmd**+scroll) over the image | Zooms between 100% and 4000%, around the pointer, so whatever you point at stays where it is. The current zoom appears in the corner of the image while it is above 100%. | | **Ctrl**+drag with the left or middle mouse button (macOS: **Cmd**+drag) | Moves the zoomed image, to bring another part of it into view. | | The scrollbars | Moves the zoomed image. The scrollbars appear as soon as the image is larger than the preview area. | +| Right-click the zoom in the corner of the image | Opens a menu to zoom to 100%, 200%, 400%, 800%, 1600%, 3200%, or 4000%, around the middle of the view. | Zooming and moving the image only change how you see it. In the live view, the device still receives your clicks, drags, and keys at the place on its screen you are pointing at, and scrolling without **Ctrl** still scrolls the device rather than the view. -The zoom of the live view and the zoom of the screenshots are separate, and both go back to 100% when scripts recompile. +The zoom of the live view and the zoom of the screenshots are separate, and both stay as they are when scripts recompile. -## Screenshot details +## Capture details -This section appears beside the image while a screenshot is selected. +This section appears beside the image while a screenshot or a recording is selected. | **Property** | **Description** | | ------------------ | ------------------------------------------------------------ | -| **Device** | The device the screenshot was captured from. Hover over it for the device ID. | +| **Device** | The device the capture was taken from. Hover over it for the device ID. | | **OS** | The Android version and API level the device was running. | -| **Display Size** | The device's display resolution when the screenshot was taken. This differs from **Image Size** if the display was rotated or its size overridden. | -| **Image Size** | The size of the image in pixels. | -| **File Size** | The size of the `.png` file on disk. | +| **Display Size** | The device's display resolution when the capture was taken. This differs from **Image Size** if the display was rotated or its size overridden. | +| **Image Size** | The size of the image in pixels. Screenshots only. | +| **Video Size** | The size of the recording in pixels. Recordings only. | +| **Frame Rate** | How many frames per second the recording plays at. Recordings only. | +| **File Size** | The size of the file on disk. | | **Captured** | When the file was last written. Hover over it for the full date and time. | -**Device**, **OS**, and **Display Size** come from the details file, saved in the same folder as the screenshot. These properties display `Undefined` if a screenshot was captured with an earlier version of this package, or if the image was added to the folder by hand. +**Device**, **OS**, and **Display Size** come from the details file, saved in the same folder as the capture. These properties display `Undefined` if a capture was taken with an earlier version of this package, or if the file was added to the folder by hand. **Video Size** and **Frame Rate** come from the recording itself, so they display `Undefined` until the recording has opened. + +A selected recording opens paused on its first frame, and loops once it plays. Use the play button below the properties to play and pause it. While it is paused, the buttons beside the play button go back or forward one frame. You can also drag the slider under it to move to another point in the recording, whether it is playing or paused. A playing recording pauses while you drag, and plays on from the new point when you let go. Next to the step buttons is the current time and the length of the recording, in seconds and hundredths of a second, with minutes for a recording a minute or longer. ## Live view details @@ -163,7 +143,7 @@ The device navigation buttons work while the live view is streaming and the devi | **Half** | Holds the device half open, regardless of the hinge position. Appears only for devices that report half open as a separate state. | | **Unfold** | Holds the device unfolded, regardless of the hinge position. | -Under **Device Capture**, **Take Screenshot** captures the device screen and adds it to the [Capture list](#capture-list). The screenshot is taken on the device rather than copied from the stream, so it is full resolution regardless of how the stream is scaled. **Ctrl+Shift+S** (macOS: **Cmd+Shift+S**) does the same while this window has focus and **Live** is selected. The shortcut has no effect while a saved capture is selected. +Under **Device Capture**, **Take Screenshot** captures the device screen and adds it to the [Capture list](#capture-list). **Ctrl+Shift+S** (macOS: **Cmd+Shift+S**) does the same while this window has focus and **Live** is selected. The shortcut has no effect while a saved capture is selected. **Take Recording** starts recording the live view and changes to **Stop Recording**, and the view shows how long it has been recording. The recording is added to the list when it stops. The [Capture list](#capture-list) is disabled while recording. Closing the window, or the live stream stopping, also stops the recording, so a recording never continues out of sight. Screenshots are taken on the device, so they are full resolution regardless of how the stream is scaled. Recordings are made from the stream, so they are the stream's size. For how to interact with the device and how to change the size, quality, and frame rate of the stream, refer to [View the device screen live](screen-capture-live-stream.md). @@ -174,7 +154,7 @@ The status bar reports what the window last did: * Where a capture was written, for example `Screenshot saved to AndroidLogcat/Captures/_1.png`. A path inside your project is shown relative to it. * What the live view is streaming, once its first frame arrives, for example `Live stream: Google Pixel 7 Pro (36081FDH3002Q8), 1080x2340 scaled to 232x512, up to 15 fps`. It is reported again when the streamed size changes, which happens when the device is rotated or a foldable is opened, and `Live stream stopped` when it ends. -Videos are written to a temporary file that the next recording from the same device replaces, so use **Save As** to keep one. Screenshots are kept until you delete them. +Every capture is kept until you delete it, screenshots and recordings alike, in the folder set by [Captures Folder](android-logcat-settings.md#capture-settings). ## Additional resources diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs index 42bb0f9b..7c6b2144 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureList.cs @@ -34,9 +34,7 @@ static class Styles /// The icon for a capture, by what kind of file it is. internal static Texture IconFor(string path) { - var extension = Path.GetExtension(path); - var video = extension == ".mp4" || extension == ".webm"; - var icon = video ? kVideo : kImage; + var icon = IsVideo(path) ? kVideo : kImage; return icon != null ? icon.image : null; } @@ -55,7 +53,10 @@ internal static Texture IconFor(string path) "Size of the file on disk."); internal static readonly GUIContent Captured = new GUIContent("Captured", "When the file was last written."); - + internal static readonly GUIContent VideoSize = new GUIContent("Video Size", + "Size of the recording in pixels."); + internal static readonly GUIContent FrameRate = new GUIContent("Frame Rate", + "Frames per second the recording plays at."); // The selected row draws on a coloured background, where the default label // colour is hard to read. static GUIStyle s_SelectedRow; @@ -115,6 +116,27 @@ internal static GUIStyle SelectedRow int m_KnownCount = -1; // Whether the one-off "what should this window open on" decision has been made. bool m_InitialSelectionDone; + // Taken up in place of that decision, after a domain reload. + Selection m_Restore; + // The frame the restored video was on, for when it opens. -1 for none. + long m_RestoreVideoFrame = -1; + + /// What is selected, which the window keeps across a domain reload. + [Serializable] + internal class Selection + { + // Unity deserializes a missing one as an empty instance rather than null. + public bool Saved; + public bool Live; + public List Paths = new List(); + public string Anchor; + public string Previewed; + public long VideoFrame = -1; + public float Zoom = AndroidLogcatImageViewer.kMinZoom; + public Vector2 Scroll; + public float LiveZoom = AndroidLogcatImageViewer.kMinZoom; + public Vector2 LiveScroll; + } // The capture that has just landed, lit until m_FlashUntil. The window is // showing the device rather than the list when one arrives, so the row is what @@ -137,9 +159,24 @@ internal static GUIStyle SelectedRow // last capture, and swapping it for a screenshot picked out of this list would // lose it. Only the selected path is shared. Texture2D m_PreviewTexture; + // A recording is previewed by playing it, where a screenshot is drawn. Only + // one of the two is ever loaded, since only one row is selected. + readonly AndroidLogcatVideoPlayer m_VideoPlayer; string m_PreviewPath; PreviewDetails m_PreviewDetails; + bool PreviewIsVideo => m_PreviewDetails != null && m_PreviewTexture == null; + + // A video opens asynchronously; there is no frame and no size until it has. + bool VideoOpening => string.IsNullOrEmpty(m_VideoPlayer.Error) + && (m_VideoPlayer.Texture == null || m_VideoPlayer.Aspect <= 0); + + /// + /// Whether the preview changes on its own, so the window has to keep repainting. + /// A repaint asked for from OnGUI alone can be lost while the Editor starts up. + /// + internal bool NeedsRepaint => PreviewIsVideo && (VideoOpening || m_VideoPlayer.NeedsRepaint); + // Zoom and pan for the preview. Its own, separate from the live view's: they // show different things, and a zoom set on one is rarely the one wanted on the // other. Kept across screenshots, though - screenshots from the same device are @@ -153,11 +190,19 @@ internal static GUIStyle SelectedRow internal bool LiveSelected => m_LiveSelected; internal AndroidLogcatCaptureList(AndroidLogcatRuntimeBase runtime, Action repaint) + : this(runtime, runtime.CaptureScreenshot, repaint, AndroidLogcatVideoPlayer.kDefaultName) + { + } + + /// For tests: own captures and player. + internal AndroidLogcatCaptureList(AndroidLogcatRuntimeBase runtime, + AndroidLogcatCaptureScreenshot captureScreenshot, Action repaint, string playerName) { m_Runtime = runtime; - m_CaptureScreenshot = runtime.CaptureScreenshot; + m_CaptureScreenshot = captureScreenshot; m_LiveStream = runtime.LiveStream; m_Repaint = repaint; + m_VideoPlayer = new AndroidLogcatVideoPlayer(playerName); // Settings saved before the width existed deserialize it as 0, which would // collapse the list to nothing. @@ -166,15 +211,76 @@ internal AndroidLogcatCaptureList(AndroidLogcatRuntimeBase runtime, Action repai settings.CaptureListWidth = kDefaultWidth; } + /// The player holds a texture and a VideoPlayer of its own. + internal void Dispose() + { + m_VideoPlayer.Dispose(); + DestroyPreview(); + } + + internal Selection SaveSelection() + { + return new Selection + { + Saved = true, + Live = m_LiveSelected, + Paths = new List(m_Selected), + Anchor = m_SelectionAnchor, + Previewed = m_CaptureScreenshot.SelectedImagePath, + VideoFrame = PreviewIsVideo ? m_VideoPlayer.Frame : -1, + Zoom = m_Viewer.Zoom, + Scroll = m_Viewer.Scroll, + LiveZoom = m_LiveStream.Viewer.Zoom, + LiveScroll = m_LiveStream.Viewer.Scroll + }; + } + + /// Applied on the first draw, in place of opening on the live view. + internal void RestoreSelection(Selection selection) + { + m_Restore = selection != null && selection.Saved ? selection : null; + if (m_Restore != null) + { + m_Viewer.Restore(m_Restore.Zoom, m_Restore.Scroll); + m_LiveStream.Viewer.Restore(m_Restore.LiveZoom, m_Restore.LiveScroll); + } + } + + void ApplyRestore(Selection selection, IAndroidLogcatDevice device) + { + if (selection.Live) + { + SetLive(true, device); + return; + } + foreach (var path in selection.Paths) + { + if (File.Exists(path)) + m_Selected.Add(path); + } + m_SelectionAnchor = m_Selected.Contains(selection.Anchor) ? selection.Anchor : null; + if (m_Selected.Count == 0) + return; + if (m_Selected.Contains(selection.Previewed)) + { + m_CaptureScreenshot.SelectImage(selection.Previewed); + m_RestoreVideoFrame = selection.VideoFrame; + } + else + { + m_CaptureScreenshot.SelectImage(First(m_Selected)); + } + } + /// - /// Stops the stream and drops the selection, for a window that is going away or - /// has switched to a mode that does not show this list. The initial selection is - /// forgotten with it, so coming back decides what to open on again. + /// Stops the stream and drops the selection, for a window that is going away. + /// The initial selection is forgotten with it, so coming back decides what to + /// open on again. /// internal void Deselect() { if (m_LiveSelected) - m_LiveStream.StopStreaming(); + StopLive(); m_LiveSelected = false; m_Selected.Clear(); m_SelectionAnchor = null; @@ -189,6 +295,10 @@ internal void Deselect() /// internal void InvalidatePreview() { + // Reloading a recording opens it again at the start, so one that has not + // changed on disk is left where it was, and paused if it was. + if (PreviewIsVideo && m_VideoPlayer.IsShowing(m_PreviewPath)) + return; DestroyPreview(); } @@ -220,22 +330,63 @@ internal bool DoPreviewGUI(Rect rc) return true; } - if (m_PreviewTexture == null) + if (m_PreviewDetails == null) return false; AndroidLogcatStatsColumn.DrawBox(rc); - // The same column the live view draws, so the two modes look alike. Taken - // out of the area before the image is fitted, or the image would be drawn + // The same column the live view draws, so the two look alike. Taken out of + // the area before the image is fitted, or the image would be drawn // underneath it, and sized to its text, or a device name is cut in half. var statsWidth = AndroidLogcatStatsColumn.WidthFor(rc, m_PreviewDetails.Values); - var imageArea = new Rect(rc.x, rc.y, Mathf.Max(0, rc.width - statsWidth), rc.height); + var previewArea = new Rect(rc.x, rc.y, Mathf.Max(0, rc.width - statsWidth), rc.height); + + Rect previewBox; + if (PreviewIsVideo) + { + var frame = m_VideoPlayer.Texture; + var aspect = m_VideoPlayer.Aspect; + var failed = !string.IsNullOrEmpty(m_VideoPlayer.Error); + var opening = VideoOpening; + if (failed) + { + EditorGUI.HelpBox(previewArea, $"The recording cannot be played.\n{m_VideoPlayer.Error}", + MessageType.Error); + previewBox = previewArea; + } + else if (opening) + { + EditorGUI.HelpBox(previewArea, "Opening the recording...", MessageType.Info); + previewBox = previewArea; + } + else + { + // Only the picture: the rest of a recording is black, wherever folding or + // turning the device left it. + var uv = m_PreviewDetails.ContentAt(m_VideoPlayer.TimeOf(m_VideoPlayer.Frame), frame.width, frame.height); + var shown = new Vector2(uv.width * frame.width, uv.height * frame.height); + var time = m_VideoPlayer.TimeOf(m_VideoPlayer.Frame); + previewBox = m_Viewer.DoGUI(previewArea, shown.y > 0 ? shown.x / shown.y : aspect, + videoRect => + { + GUI.DrawTextureWithTexCoords(videoRect, frame, uv); + DrawTouch(videoRect, time); + }, m_Repaint); + } - var imageBox = m_Viewer.DoGUI(imageArea, - (float)m_PreviewTexture.width / m_PreviewTexture.height, - imageRect => GUI.DrawTexture(imageRect, m_PreviewTexture), m_Repaint); + if (opening || m_VideoPlayer.NeedsRepaint) + m_Repaint(); + } + else + { + previewBox = m_Viewer.DoGUI(previewArea, + (float)m_PreviewTexture.width / m_PreviewTexture.height, + imageRect => GUI.DrawTexture(imageRect, m_PreviewTexture), m_Repaint); + } - DoStatsGUI(AndroidLogcatStatsColumn.RectBeside(rc, imageBox)); + // A recording's picture changes shape as the device folds or turns, and a column + // beside it would move and resize with it, slider and all - so it keeps to the right. + DoStatsGUI(AndroidLogcatStatsColumn.RectBeside(rc, PreviewIsVideo ? previewArea : previewBox)); return true; } @@ -247,12 +398,123 @@ void DoStatsGUI(Rect rc) AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Device, details.Device, details.DeviceId); AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.OS, details.OS); - AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.DisplaySize, details.DisplaySize); - AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.ImageSize, details.ImageSize); + // A recording's changes as the device is folded or turned, at the time shown. + var displaySize = PreviewIsVideo + ? details.DisplaySizeAt(m_VideoPlayer.TimeOf(m_VideoPlayer.Frame)) + : details.DisplaySize; + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.DisplaySize, displaySize); + + // A recording's size and length are known only once the player has opened it. + if (PreviewIsVideo) + { + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.VideoSize, + Undefined(m_VideoPlayer.Dimensions)); + var frameRate = m_VideoPlayer.FrameRate; + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.FrameRate, + frameRate > 0 ? $"{frameRate:0.##} fps" : kUndefined); + } + else + { + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.ImageSize, details.ImageSize); + } + AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.FileSize, details.FileSize); AndroidLogcatStatsColumn.Row(rc, kLabelWidth, ref y, Styles.Captured, details.Captured, details.CapturedInFull); + + if (PreviewIsVideo) + { + y += kGroupGap; + m_VideoPlayer.DoControlsGUI(rc, ref y); + } + else if (Unsupported.IsDeveloperMode()) + { + y += kGroupGap; + AndroidLogcatStatsColumn.DeveloperModeGUI(rc, ref y); + } + } + + // On screen, whatever the zoom: it marks a finger, not an area of the picture. + const float kTouchSize = 36; + // How long a lifted touch takes to fade, so a quick tap is still seen. + const double kTouchFadeSeconds = 0.3; + static Texture2D s_TouchMarker; + + // Tints for the marker's light parts, by what the touch was last doing. The dark + // rim stays dark, so each reads on any screen. + static readonly Color kTouchDown = new Color(0.3f, 0.9f, 0.4f); + static readonly Color kTouchMove = new Color(0.3f, 0.7f, 1f); + static readonly Color kTouchUp = Color.white; + static readonly Color kTouchCancel = new Color(1f, 0.35f, 0.35f); + + static Color TouchColor(string action) + { + switch (action) + { + case nameof(AndroidLogcatLiveStream.TouchAction.Down): return kTouchDown; + case nameof(AndroidLogcatLiveStream.TouchAction.Move): return kTouchMove; + case nameof(AndroidLogcatLiveStream.TouchAction.Cancel): return kTouchCancel; + default: return kTouchUp; + } + } + + /// + /// The touch the Editor sent at , if any. Android does not + /// draw injected touches into the screen, so the recording only has them as data. + /// + void DrawTouch(Rect videoRect, double time) + { + if (Event.current.type != EventType.Repaint) + return; + if (!m_PreviewDetails.TouchAt(time, kTouchFadeSeconds, out var position, out var alpha, out var action)) + return; + + var centre = new Vector2(videoRect.x + position.x * videoRect.width, videoRect.y + position.y * videoRect.height); + var previous = GUI.color; + var color = TouchColor(action); + color.a = alpha; + GUI.color = color; + GUI.DrawTexture(new Rect(centre.x - kTouchSize / 2, centre.y - kTouchSize / 2, kTouchSize, kTouchSize), + TouchMarker); + GUI.color = previous; + } + + /// A light disc with a dark rim, visible on light and dark screens alike. + static Texture2D TouchMarker + { + get + { + if (s_TouchMarker != null) + return s_TouchMarker; + const int kSize = 64; + s_TouchMarker = new Texture2D(kSize, kSize, TextureFormat.RGBA32, false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Bilinear + }; + var pixels = new Color32[kSize * kSize]; + var radius = kSize / 2f - 1; + for (var y = 0; y < kSize; y++) + { + for (var x = 0; x < kSize; x++) + { + var distance = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(kSize / 2f, kSize / 2f)); + // Antialiased edge, a dark rim, a white ring inside it, and a soft fill. + var edge = Mathf.Clamp01(radius - distance + 0.5f); + Color color = distance > radius - 3 ? new Color(0, 0, 0, 0.8f) + : distance > radius - 7 ? new Color(1, 1, 1, 0.95f) + : new Color(1, 1, 1, 0.45f); + color.a *= edge; + pixels[y * kSize + x] = color; + } + } + s_TouchMarker.SetPixels32(pixels); + s_TouchMarker.Apply(); + return s_TouchMarker; + } } + static string Undefined(string value) => string.IsNullOrEmpty(value) ? kUndefined : value; + /// /// What the details column says about the selected screenshot, worked out when /// it is loaded: the column is measured against these before the image is @@ -269,7 +531,9 @@ class PreviewDetails internal string Captured { get; } internal string CapturedInFull { get; } internal string[] Values { get; } + readonly AndroidLogcatScreenshotInfo.FrameChange[] m_FrameChanges; + /// A recording has no texture, so it has no image size either. internal PreviewDetails(Texture2D texture, FileInfo file, AndroidLogcatScreenshotInfo info) { Device = info == null || string.IsNullOrEmpty(info.deviceName) ? kUndefined : info.deviceName; @@ -278,12 +542,94 @@ internal PreviewDetails(Texture2D texture, FileInfo file, AndroidLogcatScreensho DisplaySize = info == null || info.displayWidth <= 0 ? kUndefined : $"{info.displayWidth}x{info.displayHeight}"; - ImageSize = $"{texture.width}x{texture.height}"; + m_FrameChanges = info?.frameChanges; + ImageSize = texture != null ? $"{texture.width}x{texture.height}" : kUndefined; FileSize = EditorUtility.FormatBytes(file.Length); Captured = file.LastWriteTime.ToString("g"); CapturedInFull = file.LastWriteTime.ToString("F"); Values = new[] { Device, OS, DisplaySize, ImageSize, FileSize, Captured }; } + + /// The display size seconds into a recording. + internal string DisplaySizeAt(double time) + { + var display = LatestAt(time, change => change.display != null && change.display.IsSet ? change.display : null); + return display != null ? $"{display.width}x{display.height}" : DisplaySize; + } + + /// + /// The part of a recording's frame with the picture in it at , + /// as texture coordinates. All of it for a recording that does not say. + /// + internal Rect ContentAt(double time, int videoWidth, int videoHeight) + { + var content = LatestAt(time, change => change.content != null && change.content.IsSet ? change.content : null); + if (content == null || videoWidth <= 0 || videoHeight <= 0) + return new Rect(0, 0, 1, 1); + // A pixel in from each side: compression bleeds the black into the edge. + // Centred, so the flip between pixel rows and texture coordinates cancels out. + const int kInset = 1; + var width = Mathf.Max(1, content.width - 2 * kInset); + var height = Mathf.Max(1, content.height - 2 * kInset); + return new Rect((float)(content.x + kInset) / videoWidth, (float)(content.y + kInset) / videoHeight, + (float)width / videoWidth, (float)height / videoHeight); + } + + /// + /// Where an Editor touch was at : held down, or lifted less + /// than before, fading out. False when there is none. + /// + internal bool TouchAt(double time, double fadeSeconds, out Vector2 position, out float alpha, out string action) + { + position = default; + alpha = 0; + action = null; + AndroidLogcatScreenshotInfo.TouchInfo last = null; + var lastTime = 0.0; + if (m_FrameChanges == null) + return false; + foreach (var change in m_FrameChanges) + { + if (change.time > time + 0.0005) + break; + if (change.touches == null || change.touches.Count == 0) + continue; + last = change.touches[change.touches.Count - 1]; + lastTime = change.time; + } + if (last == null) + return false; + + position = new Vector2(last.x, last.y); + action = last.action; + var down = last.action == nameof(AndroidLogcatLiveStream.TouchAction.Down) + || last.action == nameof(AndroidLogcatLiveStream.TouchAction.Move); + if (down) + { + alpha = 1; + return true; + } + var since = time - lastTime; + if (since > fadeSeconds) + return false; + alpha = (float)(1 - since / fadeSeconds); + return true; + } + + T LatestAt(double time, Func pick) where T : class + { + T latest = null; + if (m_FrameChanges == null) + return latest; + foreach (var change in m_FrameChanges) + { + // A hair early, so the frame a change lands on already shows it. + if (change.time > time + 0.0005) + break; + latest = pick(change) ?? latest; + } + return latest; + } } static string OperatingSystem(AndroidLogcatScreenshotInfo info) @@ -299,7 +645,7 @@ static string OperatingSystem(AndroidLogcatScreenshotInfo info) /// capture landing, a delete, a rename - so there is one path to get wrong /// instead of four. /// - void SyncPreview() + internal void SyncPreview() { var path = m_CaptureScreenshot.SelectedImagePath; if (path == m_PreviewPath) @@ -311,6 +657,15 @@ void SyncPreview() if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; + if (IsVideo(path)) + { + m_PreviewDetails = new PreviewDetails(null, new FileInfo(path), + AndroidLogcatScreenshotInfo.Load(path)); + m_VideoPlayer.Open(path, m_RestoreVideoFrame); + m_RestoreVideoFrame = -1; + return; + } + var texture = new Texture2D(2, 2); bool loaded; using (k_LoadPreview.Auto()) @@ -328,8 +683,22 @@ void SyncPreview() } } + internal static bool IsVideo(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".mp4", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".webm", StringComparison.OrdinalIgnoreCase); + } + + /// Null when nothing is loaded. + internal string PreviewPath => m_PreviewPath; + + internal AndroidLogcatVideoPlayer VideoPlayer => m_VideoPlayer; + void DestroyPreview() { + if (PreviewIsVideo) + m_VideoPlayer.Open(null); if (m_PreviewTexture != null) UnityEngine.Object.DestroyImmediate(m_PreviewTexture); m_PreviewTexture = null; @@ -343,6 +712,9 @@ void DestroyPreview() /// internal void OnDeviceChanged(IAndroidLogcatDevice device) { + // A recording belongs to the device it started on, so it ends before the + // stream moves to another. + m_Runtime.CaptureVideo?.StopRecording(); if (m_LiveSelected) m_LiveStream.RestartStreaming(device); } @@ -371,7 +743,10 @@ internal Rect DoGUI(Rect rc, IAndroidLogcatDevice device) var listRect = new Rect(rc.x, rc.y, width, rc.height); var splitterRect = new Rect(listRect.xMax, rc.y, kSplitterWidth, rc.height); - DoColumnGUI(listRect, device); + // A recording only runs while the live view is showing, and selecting a + // capture would end it - so nothing in the column can be picked until it stops. + using (new EditorGUI.DisabledScope(m_Runtime.CaptureVideo.IsRecording)) + DoColumnGUI(listRect, device); // Clamped to the window, so storing it unmoved would shrink a saved width // that this window is too narrow to show. @@ -435,15 +810,18 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) SyncPreview(); PruneSelection(captures); - // With nothing selected - a first run, or a domain reload, which does not - // remember what was selected - open on the live view rather than on an - // empty pane. Once per window: deleting the last capture deliberately - // leaves nothing selected rather than starting a stream. + // A domain reload puts back what was selected. Otherwise, with nothing + // selected, open on the live view rather than on an empty pane. Once per + // window: deleting the last capture deliberately leaves nothing selected + // rather than starting a stream. if (!m_InitialSelectionDone) { m_InitialSelectionDone = true; - if (!m_LiveSelected && m_Selected.Count == 0) + if (m_Restore != null) + ApplyRestore(m_Restore, device); + else if (!m_LiveSelected && m_Selected.Count == 0) SetLive(true, device); + m_Restore = null; } var rowHeight = EditorGUIUtility.singleLineHeight; @@ -456,6 +834,12 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) var scrollbarWidth = contentHeight > inner.height ? kScrollbarWidth : 0; var content = new Rect(0, 0, inner.width - scrollbarWidth, contentHeight); var hasFocus = GUIUtility.keyboardControl == controlId; + // The rows read mouse and key events themselves, which GUI.enabled does not stop. + var interactive = GUI.enabled; + // Any button when none is given. + bool Pressed(Rect rect, EventType type, int button = -1) => + interactive && Event.current.type == type && (button < 0 || Event.current.button == button) + && rect.Contains(Event.current.mousePosition); // A row that cannot be seen cannot say anything. if (m_FlashNeedsScroll && m_FlashPath != null) @@ -500,7 +884,15 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) var icon = Styles.IconFor(path); if (icon != null && Event.current.type == EventType.Repaint) + { + // DrawTexture ignores GUI.enabled, so it is faded by hand the way + // disabled styles fade their text. + var color = GUI.color; + if (!interactive) + GUI.color = new Color(color.r, color.g, color.b, color.a * 0.5f); GUI.DrawTexture(iconRect, icon, ScaleMode.ScaleToFit); + GUI.color = color; + } if (path == m_RenamingPath) { @@ -519,8 +911,7 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) // The whole row, icon included. Skipped while this row is being // renamed, so clicking into the text field does not count as selecting // the row. - if (Event.current.type == EventType.MouseDown && Event.current.button == 0 - && rowRect.Contains(Event.current.mousePosition) && path != m_RenamingPath) + if (Pressed(rowRect, EventType.MouseDown, 0) && path != m_RenamingPath) { GUIUtility.keyboardControl = controlId; ClickRow(captures, path, Event.current, device); @@ -533,16 +924,14 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) // On the press, so the row is drawn selected before the menu opens // over it. Not consumed: the ContextClick is made from this event. - if (Event.current.type == EventType.MouseDown && Event.current.button == 1 - && rowRect.Contains(Event.current.mousePosition) && path != m_RenamingPath + if (Pressed(rowRect, EventType.MouseDown, 1) && path != m_RenamingPath && !m_Selected.Contains(path)) { GUIUtility.keyboardControl = controlId; SelectOnly(path, device); } - if (Event.current.type == EventType.ContextClick - && rowRect.Contains(Event.current.mousePosition)) + if (Pressed(rowRect, EventType.ContextClick)) { GUIUtility.keyboardControl = controlId; // A click inside the selection acts on the whole of it; one @@ -561,15 +950,15 @@ void DoCapturesGUI(Rect rc, IAndroidLogcatDevice device) GUI.EndScrollView(); // Deselect when clicking on empty space - if (Event.current.type == EventType.MouseDown && Event.current.button == 0 - && inner.Contains(Event.current.mousePosition) && m_RenamingPath == null) + if (Pressed(inner, EventType.MouseDown, 0) && m_RenamingPath == null) { GUIUtility.keyboardControl = controlId; DeselectAll(); Event.current.Use(); } - HandleKeys(controlId, captures, rowHeight, inner.height, device); + if (interactive) + HandleKeys(controlId, captures, rowHeight, inner.height, device); if (menuPath != null) ShowRowContextMenu(captures, menuPath, GUIUtility.ScreenToGUIPoint(menuScreenPosition)); @@ -640,11 +1029,22 @@ void SetLive(bool live, IAndroidLogcatDevice device) } else { - m_LiveStream.StopStreaming(); + StopLive(); } m_Repaint(); } + /// + /// A recording is taken while watching the device, and once the live view is + /// gone nothing says one is still running - so it ends with it. The stream goes + /// first, so the status bar ends up saying where the recording landed. + /// + void StopLive() + { + m_LiveStream.StopStreaming(); + m_Runtime.CaptureVideo.StopRecording(); + } + /// /// Shift extends from the last click, Ctrl - Cmd on macOS - adds and removes /// one, and a plain click replaces the selection, as lists elsewhere behave. @@ -845,11 +1245,30 @@ void CommitRename() // An unchanged or unusable name is not an error; the row just goes back to // showing what it showed before. - if (m_CaptureScreenshot.RenameScreenshot(path, name, out var renamed)) - FollowRename(path, renamed); + RenameCapture(path, name); m_Repaint(); } + /// The selection follows the rename. + internal bool RenameCapture(string path, string name) + { + ClosePreviewOf(path); + if (!m_CaptureScreenshot.RenameScreenshot(path, name, out var renamed)) + return false; + FollowRename(path, renamed); + return true; + } + + /// + /// Windows cannot move or delete a file the player has open. The next pass + /// reopens whatever is selected. + /// + void ClosePreviewOf(string path) + { + if (PreviewIsVideo && m_PreviewPath == path) + DestroyPreview(); + } + void CancelRename() { m_RenamingPath = null; @@ -907,20 +1326,16 @@ void CopyTo(IReadOnlyList paths) if (paths.Count == 0) return; - // Captures are always copied from the Screenshot mode's remembered - // location, whatever mode the window happens to be in. var settings = m_Runtime.UserSettings.CaptureSettings; - const AndroidLogcatScreenCaptureWindow.Mode mode = - AndroidLogcatScreenCaptureWindow.Mode.Screenshot; if (paths.Count == 1) { - settings.SaveFileAs(mode, paths[0], "Copy Screenshot"); + settings.SaveFileAs(paths[0], "Copy Capture"); return; } var directory = EditorUtility.OpenFolderPanel($"Copy {paths.Count} Captures", - settings.GetLastSaveLocation(mode), string.Empty); + settings.LastSaveLocation, string.Empty); if (string.IsNullOrEmpty(directory)) return; @@ -935,7 +1350,7 @@ void CopyTo(IReadOnlyList paths) } if (copied > 0) - settings.SetLastSaveLocation(mode, Path.GetFullPath(directory)); + settings.LastSaveLocation = Path.GetFullPath(directory); } /// @@ -1073,9 +1488,17 @@ void ConfirmAndDelete(IReadOnlyList c "Delete", "Cancel")) return; + DeleteCaptures(captures, paths, device); + } + + /// Deletes without asking, then selects the row that took the first one's place. + internal void DeleteCaptures(IReadOnlyList captures, + IReadOnlyList paths, IAndroidLogcatDevice device) + { var firstRow = IndexOf(captures, paths[0]); foreach (var path in paths) { + ClosePreviewOf(path); if (m_CaptureScreenshot.DeleteScreenshot(path)) m_Selected.Remove(path); } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs index 341bdd7a..8f4d5b64 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureScreenshot.cs @@ -22,7 +22,7 @@ internal class AndroidLogcatCaptureScreenCaptureInput : IAndroidLogcatTaskInput internal class AndroidLogcatCaptureScreenCaptureResult : IAndroidLogcatTaskResult { internal string imagePath; - // The path AllocateImagePath handed out, which is imagePath on success and + // The path AllocateCapturePath handed out, which is imagePath on success and // still needed on failure - that is the reservation to release. internal string reservedPath; internal string deviceId; @@ -132,21 +132,25 @@ public string GetLatestImagePath(IAndroidLogcatDevice device) var prefix = AndroidLogcatUtilities.SanitizeFileName(device.Id); var screenshots = GetScreenshots(); - // Ordered by number within a device, so the last match is the newest. + // Ordered by number within a device, so the last match is the newest. Images + // only: recordings are listed too, and are no use to anyone loading a texture. for (var i = screenshots.Count - 1; i >= 0; i--) { - if (screenshots[i].DevicePrefix == prefix) + if (screenshots[i].DevicePrefix == prefix + && screenshots[i].Path.EndsWith(GetImageExtension(), StringComparison.OrdinalIgnoreCase)) return screenshots[i].Path; } return string.Empty; } /// - /// Reserves the next free path, <device_id>_<number>.png under - /// the capture directory, and makes sure it exists - adb pull will not create it. - /// Internal rather than private so a test can reserve without a device. + /// Reserves the next free path, <device_id>_<number> plus + /// under the capture directory, and makes sure the + /// directory exists - adb pull will not create it. Screenshots and recordings + /// share the numbering: a recording is a capture like any other. Released with + /// . /// - internal string AllocateImagePath(IAndroidLogcatDevice device) + internal string AllocateCapturePath(IAndroidLogcatDevice device, string extension) { var directory = m_Directory(); Directory.CreateDirectory(directory); @@ -155,7 +159,7 @@ internal string AllocateImagePath(IAndroidLogcatDevice device) var screenshots = GetScreenshots(); if (!m_KeepHistory) - return AllocateSingleImagePath(directory, prefix); + return AllocateSinglePath(directory, prefix, extension); // Numbering is per device, so only this device's entries count. var number = 1; @@ -165,7 +169,7 @@ internal string AllocateImagePath(IAndroidLogcatDevice device) number = screenshot.Number + 1; } - var path = Path.Combine(directory, $"{prefix}_{number}{GetImageExtension()}").Replace("\\", "/"); + var path = Path.Combine(directory, $"{prefix}_{number}{extension}").Replace("\\", "/"); // The reservation is what stops a second capture queued before this file // exists from picking the same number - the list is counted from, not the @@ -181,11 +185,11 @@ internal string AllocateImagePath(IAndroidLogcatDevice device) /// /// The single slot of a capture that keeps no history: always the same file, /// with everything captured before it - including a capture of another device - - /// deleted, so the directory holds one screenshot and no more. + /// deleted, so the directory holds one capture and no more. /// - private string AllocateSingleImagePath(string directory, string prefix) + private string AllocateSinglePath(string directory, string prefix, string extension) { - var path = Path.Combine(directory, $"{prefix}_1{GetImageExtension()}").Replace("\\", "/"); + var path = Path.Combine(directory, $"{prefix}_1{extension}").Replace("\\", "/"); foreach (var screenshot in GetScreenshots()) { @@ -203,13 +207,25 @@ private string AllocateSingleImagePath(string directory, string prefix) return path; } + /// Screenshots and recordings, which are both captures in this list. + private IEnumerable EnumerateCaptures(string directory) + { + foreach (var file in Directory.GetFiles(directory, $"*{GetImageExtension()}")) + yield return file; + if (m_KeepHistory) + { + foreach (var file in Directory.GetFiles(directory, $"*{GetVideoExtension()}")) + yield return file; + } + } + private List ScanScreenshots(string directory) { var screenshots = new List(); if (!Directory.Exists(directory)) return screenshots; - foreach (var file in Directory.GetFiles(directory, $"*{GetImageExtension()}")) + foreach (var file in EnumerateCaptures(directory)) { var name = Path.GetFileNameWithoutExtension(file); @@ -309,19 +325,25 @@ public bool RenameScreenshot(string path, string newName, out string renamed) return false; } + // A recording stays a recording: the extension is what says which it is. + var extension = Path.GetExtension(path); var directory = Path.GetDirectoryName(path); - var target = Path.Combine(directory, newName + GetImageExtension()).Replace("\\", "/"); + var target = Path.Combine(directory, newName + extension).Replace("\\", "/"); if (target == path) { renamed = path; return true; } - if (File.Exists(target)) + // Any kind: a screenshot and a recording of one name would share a details file. + foreach (var taken in new[] { extension, GetImageExtension(), GetVideoExtension() }) { - UnityEngine.Debug.LogError( - $"'{newName}{GetImageExtension()}' already exists. Choose a different name."); - return false; + if (File.Exists(Path.Combine(directory, newName + taken))) + { + UnityEngine.Debug.LogError( + $"'{newName}{taken}' already exists. Choose a different name."); + return false; + } } // Checked before the image moves, so the two cannot end up apart. @@ -390,6 +412,21 @@ public string GetImageExtension() return ".png"; } + public string GetVideoExtension() + { + return ".mp4"; + } + + /// + /// Ends a reservation made by , once the capture + /// has landed or failed. Until then the scan keeps listing it with no file behind it. + /// + internal void ReleaseCapturePath(string path) + { + if (m_ReservedPaths.Remove(path)) + InvalidateScreenshots(); + } + internal AndroidLogcatCaptureScreenshot(AndroidLogcatRuntimeBase runtime, Func directory, bool keepHistory) { m_Runtime = runtime; @@ -412,7 +449,7 @@ public void QueueScreenCapture(IAndroidLogcatDevice device, Action onCompleted, string imagePath; try { - imagePath = AllocateImagePath(device); + imagePath = AllocateCapturePath(device, GetImageExtension()); } catch (Exception ex) { diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureVideo.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureVideo.cs index 6e30c415..3a4095b0 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureVideo.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatCaptureVideo.cs @@ -1,13 +1,14 @@ using System; -using UnityEngine; -using UnityEditor; +using System.Collections.Generic; using System.IO; -using System.Diagnostics; -using System.Text; -using System.Threading; +using UnityEditor; namespace Unity.Android.Logcat { + /// + /// Records the live stream into a capture, through . + /// A recording needs the stream running, and ends when the stream does. + /// internal class AndroidLogcatCaptureVideo { internal enum Result @@ -16,311 +17,166 @@ internal enum Result Failure } - internal static readonly string VideoPathOnDevice = "/sdcard/logcat_video.mp4"; - private AndroidLogcatRuntimeBase m_Runtime; - private Process m_RecordingProcess; - private StringBuilder m_RecordingProcessLog; - private StringBuilder m_RecordingProcessErrors; - private IAndroidLogcatDevice m_RecordingOnDevice; - private DateTime m_RecordingCheckTime; - private Action m_OnStopRecording; - internal string Errors => m_RecordingProcessErrors != null ? m_RecordingProcessErrors.ToString() : string.Empty; - internal string GetVideoPath(IAndroidLogcatDevice device) - { - return AndroidLogcatUtilities.GetTemporaryPath(device, "video", ".mp4"); - } + readonly AndroidLogcatRuntimeBase m_Runtime; + AndroidLogcatStreamRecorder m_Recorder; + Action m_OnStopRecording; + // Not the wall clock, which a clock change would move the time limit with. + readonly System.Diagnostics.Stopwatch m_Clock = new System.Diagnostics.Stopwatch(); + + /// + /// Why the last recording failed or stopped early, empty when it did not. One that + /// stopped early is still kept, with what was written before. + /// + internal string Errors { get; private set; } = string.Empty; + + /// The recording being taken, or the last one. Empty before the first. + internal string RecordingPath { get; private set; } = string.Empty; + + internal bool IsRecording => m_Recorder != null; + + internal TimeSpan Elapsed => IsRecording ? m_Clock.Elapsed : TimeSpan.Zero; internal AndroidLogcatCaptureVideo(AndroidLogcatRuntimeBase runtime) { m_Runtime = runtime; m_Runtime.Update += Update; - m_Runtime.Closing += Cleanup; + m_Runtime.Closing += Close; + // A reload ends the recorder without closing the file, and an MP4 is only + // playable once its index is written on close - so it is saved first. + AssemblyReloadEvents.beforeAssemblyReload += SaveBeforeReload; } - private void Cleanup() + void Close() { - if (m_RecordingOnDevice == null || m_Runtime == null) - return; - // Cache, since StopRecording will clear m_RecordingOnDevice - var device = m_RecordingOnDevice; + AssemblyReloadEvents.beforeAssemblyReload -= SaveBeforeReload; StopRecording(); - DeleteVideoOnDevice(device); - AndroidLogcatUtilities.KillScreenRecordProcess(m_Runtime, device); - m_Runtime = null; - } - - private void Update() - { - if (!IsRecording) - return; - - var currentTime = DateTime.Now; - if ((currentTime - m_RecordingCheckTime).TotalSeconds > 1.0f) - { - m_RecordingCheckTime = currentTime; - if (m_RecordingProcess.HasExited) - { - var result = Result.Failure; - var targetPath = GetVideoPath(m_RecordingOnDevice); - // screenrecord has quit without errors, for ex., hit a time limit - // Note: On Google Pixel with Android 11 if screenrecord fails - our process exits with non zero exit code, but - // On Cube U83 with Android 6 if screenrecord fails - process exits with 0 exit code, thus we additionally check if we were able to collect the recording - if (m_RecordingProcess.ExitCode == 0) - { - if (CollectRecording(targetPath)) - result = Result.Success; - - } - - var title = $"Process 'adb {m_RecordingProcess.StartInfo.Arguments}' has exited with code {m_RecordingProcess.ExitCode}."; - - if (result == Result.Failure) - { - m_RecordingProcessErrors.AppendLine(title); - m_RecordingProcessErrors.AppendLine(); - m_RecordingProcessErrors.AppendLine(m_RecordingProcessLog.ToString()); - } - AndroidLogcatInternalLog.Log(title); - AndroidLogcatInternalLog.Log(m_RecordingProcessLog.ToString()); - - m_OnStopRecording?.Invoke(result, targetPath); - ClearRecordingData(); - } - } - } - - internal bool IsRemoteRecorderActive(IAndroidLogcatDevice device) - { - return AndroidLogcatUtilities.GetPidFromPackageName(m_Runtime.Tools.ADB, device, "screenrecord") != -1; } - private void DeleteVideoOnHost(string path) + void SaveBeforeReload() { - try - { - if (File.Exists(path)) - File.Delete(path); - } - catch (Exception ex) - { - var msg = $"Failed to delete {path}\n{ex.Message}"; - UnityEngine.Debug.LogWarning(msg); - AndroidLogcatInternalLog.Log(msg); - } + StopRecording(); } - private void DeleteVideoOnDevice(IAndroidLogcatDevice device) + void Update() { - AndroidLogcatInternalLog.Log($"Clean '{VideoPathOnDevice}'"); - try - { - m_Runtime.Tools.ADB.Run(new[] - { - $"-s {device.Id}", - $"shell rm -f {VideoPathOnDevice}" - }, "Failed to delete"); - } - catch (Exception ex) - { - AndroidLogcatInternalLog.Log(ex.ToString()); - } + // Nothing more will arrive: a capture was selected, or the stream failed. + if (IsRecording && !m_Runtime.LiveStream.IsStreaming) + StopRecording(); + if (IsRecording && Elapsed.TotalMinutes >= m_Runtime.Settings.RecordingTimeLimit) + StopRecording(); + // The encoder failed, so nothing more will be written. + if (IsRecording && !string.IsNullOrEmpty(m_Recorder.Error)) + StopRecording(); } - internal bool IsRecording => m_RecordingProcess != null; - - internal void StartRecording(IAndroidLogcatDevice device, - Action onStopRecording, - TimeSpan? timeLimit = null, - uint? videoSizeX = null, - uint? videoSizeY = null, - ulong? bitRate = null, - string displayId = null) + internal void StartRecording(IAndroidLogcatDevice device, Action onStopRecording) { if (device == null) throw new InvalidOperationException("No device selected"); - - if (m_RecordingProcess != null) + if (IsRecording) throw new InvalidOperationException("Already recording"); - - m_OnStopRecording = onStopRecording; - m_RecordingOnDevice = device; - - DeleteVideoOnHost(GetVideoPath(device)); - AndroidLogcatUtilities.KillScreenRecordProcess(m_Runtime, m_RecordingOnDevice); - - // If for some reason screen recorder is still running, abort. - if (IsRemoteRecorderActive(m_RecordingOnDevice)) + var stream = m_Runtime.LiveStream; + if (stream == null || !stream.IsStreaming) + throw new InvalidOperationException("Recording needs the live stream"); + + Errors = string.Empty; + var captures = m_Runtime.CaptureScreenshot; + // Numbered like a screenshot, and reserved the same way. + var path = captures.AllocateCapturePath(device, captures.GetVideoExtension()); + // The constructor reports failures through Error rather than throwing. + var recorder = new AndroidLogcatStreamRecorder(path, m_Runtime.Settings.LiveStreamMaxFps, stream.Texture, + stream.DisplayRotation, stream.DisplaySize, stream.QuarterTurnsFor, + m_Runtime.Settings.RecordingSquareVideo ? stream.LongestFrameSide() : 0); + // The encoder is created from the live view's frame, and failing to is + // reported here rather than as a recording that never writes anything. + if (!string.IsNullOrEmpty(recorder.Error)) { - m_RecordingOnDevice = null; - throw new InvalidOperationException("screenrecord is already recording on the device, aborting..."); + Errors = recorder.Error; + recorder.Stop(); + AndroidLogcatInternalLog.Log(recorder.Stats()); + DeleteRecording(path); + captures.ReleaseCapturePath(path); + throw new InvalidOperationException(Errors); } - - DeleteVideoOnDevice(m_RecordingOnDevice); - - var args = $"-s {m_RecordingOnDevice.Id} shell screenrecord"; - if (timeLimit != null) - args += $" --time-limit {((TimeSpan)timeLimit).TotalSeconds}"; - if (videoSizeX != null && videoSizeY != null) - args += $" --size {videoSizeX}x{videoSizeY}"; - if (bitRate != null) - args += $" --bit-rate {bitRate}"; - if (displayId != null) - args += $" --display-id {displayId}"; - - args += $" {VideoPathOnDevice}"; - - AndroidLogcatInternalLog.Log($"{m_Runtime.Tools.ADB.GetADBPath()} {args}"); - - m_RecordingProcessLog = new StringBuilder(); - m_RecordingProcessErrors = new StringBuilder(); - - m_RecordingProcess = new Process(); - var si = m_RecordingProcess.StartInfo; - si.FileName = m_Runtime.Tools.ADB.GetADBPath(); - si.Arguments = args; - si.RedirectStandardError = true; - si.RedirectStandardOutput = true; - si.RedirectStandardInput = true; - si.UseShellExecute = false; - si.CreateNoWindow = true; - m_RecordingProcess.OutputDataReceived += OutputDataReceived; - m_RecordingProcess.ErrorDataReceived += OutputDataReceived; - m_RecordingProcess.Start(); - - m_RecordingProcess.BeginOutputReadLine(); - m_RecordingProcess.BeginErrorReadLine(); - - m_RecordingCheckTime = DateTime.Now; - } - - private void OutputDataReceived(object sender, DataReceivedEventArgs e) - { - if (e.Data == null) - return; - m_RecordingProcessLog.AppendLine(e.Data); + RecordingPath = path; + m_Recorder = recorder; + // The same details a screenshot is saved with, so the list can say which device it came from. + AndroidLogcatScreenshotInfo.Create(device)?.Save(RecordingPath); + m_OnStopRecording = onStopRecording; + m_Clock.Restart(); + stream.Recorder = m_Recorder; } + /// Returns false when nothing was recording, or nothing was recorded. internal bool StopRecording() { - if (m_RecordingProcess == null) + var recorder = m_Recorder; + if (recorder == null) return false; - var targetPath = GetVideoPath(m_RecordingOnDevice); - var result = Result.Success; - try - { - m_RecordingProcess.Kill(); - m_RecordingProcess.WaitForExit(); - m_RecordingProcess.Close(); - - if (!CollectRecording(targetPath)) - { - m_RecordingProcessErrors.AppendLine($"Failed to collect the recording '{VideoPathOnDevice}' -> '{targetPath}'"); - result = Result.Failure; - } - } - catch (Exception ex) + // Cleared before the callback, which can leave the live view and stop again. + var onStopRecording = m_OnStopRecording; + m_Recorder = null; + m_OnStopRecording = null; + var stream = m_Runtime.LiveStream; + if (stream != null) + stream.Recorder = null; + + var recorded = recorder.Stop(); + AndroidLogcatInternalLog.Log(recorder.Stats()); + var path = recorder.Path; + Errors = recorder.Error; + if (!recorded) { - result = Result.Failure; - m_RecordingProcessErrors.AppendLine("Failed to stop the recording"); - m_RecordingProcessErrors.AppendLine(ex.Message); + if (string.IsNullOrEmpty(Errors)) + Errors = "No frames were recorded."; + DeleteRecording(path); } - finally + else { - m_OnStopRecording?.Invoke(result, targetPath); - ClearRecordingData(); + SaveFrameChanges(path, recorder.FrameChanges); } + m_Runtime.CaptureScreenshot.ReleaseCapturePath(path); - return result == Result.Success; + onStopRecording?.Invoke(recorded ? Result.Success : Result.Failure, path); + return recorded; } - private bool CollectRecording(string targetPath) + /// + /// Folding or turning the device during a recording changes its display, so the + /// details saved at the start get what changed over time. + /// + static void SaveFrameChanges(string path, IReadOnlyList changes) { - var result = true; - if (!CopyVideoFromDevice(m_RecordingOnDevice, targetPath)) + if (changes.Count == 0) + return; + var info = AndroidLogcatScreenshotInfo.Load(path); + if (info == null) + return; + var first = changes[0].display; + if (first.IsSet) { - result = false; - AndroidLogcatUtilities.KillScreenRecordProcess(m_Runtime, m_RecordingOnDevice); + info.displayWidth = first.width; + info.displayHeight = first.height; } - - DeleteVideoOnDevice(m_RecordingOnDevice); - return result; + info.frameChanges = new AndroidLogcatScreenshotInfo.FrameChange[changes.Count]; + for (var i = 0; i < changes.Count; i++) + info.frameChanges[i] = changes[i]; + info.Save(path); } - private void ClearRecordingData() + static void DeleteRecording(string path) { - m_RecordingProcess = null; - m_RecordingOnDevice = null; - m_OnStopRecording = null; - } - - private bool CopyVideoFromDevice(IAndroidLogcatDevice device, string targetPath) - { - if (device == null) - return false; - + AndroidLogcatScreenshotInfo.Delete(path); try { - // Need to wait for Android Screen recording to finish up - // Otherwise the video will be incomplete - while (true) - { - if (!IsRemoteRecorderActive(device)) - break; - if (EditorUtility.DisplayCancelableProgressBar("Waiting for recording to finish", "Waiting for 'screenrecord' process to quit", 0.3f)) - { - EditorUtility.ClearProgressBar(); - return false; - } - Thread.Sleep(100); - } - - EditorUtility.DisplayProgressBar("Acquiring recording", $"Copy {VideoPathOnDevice} -> Temp/{Path.GetFileName(targetPath)}", 0.6f); - - try - { - var msg = m_Runtime.Tools.ADB.Run(new[] - { - $"-s {m_RecordingOnDevice.Id}", - $"pull {VideoPathOnDevice} \"{targetPath}\"" - }, "Failed to copy"); - AndroidLogcatInternalLog.Log(msg); - } - catch (Exception ex) - { - AndroidLogcatInternalLog.Log(ex.Message); - return false; - } - } - finally - { - EditorUtility.ClearProgressBar(); - } - - return true; - } - - internal void DoDebuggingGUI() - { - GUILayout.Label("Developer Mode is on, showing debugging buttons:", EditorStyles.boldLabel); - EditorGUILayout.BeginHorizontal(AndroidLogcatStyles.toolbar); - if (GUILayout.Button("Delete recording on device", AndroidLogcatStyles.toolbarButton)) - { - DeleteVideoOnDevice(m_RecordingOnDevice); + if (File.Exists(path)) + File.Delete(path); } - - if (GUILayout.Button("Get screen record pid", AndroidLogcatStyles.toolbarButton)) + catch (Exception ex) { - var pid = AndroidLogcatUtilities.GetPidFromPackageName(m_Runtime.Tools.ADB, m_Runtime.DeviceQuery.SelectedDevice, "screenrecord"); - UnityEngine.Debug.Log("screen record pid is " + pid); + AndroidLogcatInternalLog.Log($"Failed to delete '{path}': {ex.Message}"); } - - if (GUILayout.Button("Copy Recording from device", AndroidLogcatStyles.toolbarButton)) - CopyVideoFromDevice(m_RecordingOnDevice, GetVideoPath(m_RecordingOnDevice)); - - - EditorGUILayout.EndHorizontal(); } - } } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs index b4e476a2..1eef419a 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatDevice.cs @@ -123,6 +123,12 @@ internal enum DeviceState /// internal abstract FoldStates QueryFoldStates(); + /// + /// The size of each of the device's own screens, on or off - both of a + /// foldable's, say. Empty when it cannot be told. + /// + internal abstract IReadOnlyList QueryPhysicalDisplaySizes(); + /// /// Holds the device in one of the states reports. /// A negative identifier hands it back to the device's own sensors. @@ -399,6 +405,43 @@ internal override FoldStates QueryFoldStates() return new FoldStates(folded, half, unfolded); } + // DisplayDeviceInfo{"Inner Display": uniqueId="local:46...", 2076 x 2152, ... - one + // line of `dumpsys display` per screen, the screen's own size whether on or off. + static readonly Regex PhysicalDisplayRegex = + new Regex(@"DisplayDeviceInfo\{""[^""]*"": uniqueId=""[^""]*"", (?\d+) x (?\d+)"); + + internal override IReadOnlyList QueryPhysicalDisplaySizes() + { + var sizes = new List(); + if (m_Device == null || State != DeviceState.Connected) + return sizes; + + string output; + try + { + output = m_ADB.Run(new[] { $"-s {Id}", "shell", "dumpsys", "display" }, + "Failed to query the device's displays"); + } + catch (Exception ex) + { + AndroidLogcatInternalLog.Log($"Failed to query the device's displays: {ex.Message}"); + return sizes; + } + + foreach (var line in output.Split('\n')) + { + // Built in screens only: one plugged in or virtual is not what is streamed. + if (!line.Contains("type INTERNAL")) + continue; + var match = PhysicalDisplayRegex.Match(line); + if (match.Success) + sizes.Add(new Vector2Int( + int.Parse(match.Groups["w"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["h"].Value, CultureInfo.InvariantCulture))); + } + return sizes; + } + internal override void SetDeviceState(int identifier) { if (m_Device == null || State != DeviceState.Connected) diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs index 4337097f..4f4c3155 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatImageViewer.cs @@ -22,7 +22,8 @@ static class Styles static readonly string Gestures = $"{kModifier}+Wheel over the image zooms between 100% and 4000%, " + - $"{kModifier}+Left or middle mouse drag moves the zoomed image"; + $"{kModifier}+Left or middle mouse drag moves the zoomed image, " + + "right click here picks a zoom"; internal static GUIContent Zoom(int percent) { @@ -31,9 +32,12 @@ internal static GUIContent Zoom(int percent) static GUIStyle s_Badge; + // Drawn under a badge: opaque enough to read over any image, light or dark. + internal static readonly Color BadgeBackground = new Color(0, 0, 0, 0.75f); + /// - /// A help box that does not wrap. The standard one does, and CalcSize being - /// a fraction short of what it then needs breaks "168%" across two lines. + /// Bold white text, as the badge sits over the image itself. Not wrapped: + /// CalcSize being a fraction short would break "168%" across two lines. /// internal static GUIStyle Badge { @@ -41,25 +45,47 @@ internal static GUIStyle Badge { if (s_Badge == null) { - s_Badge = new GUIStyle(EditorStyles.helpBox); + s_Badge = new GUIStyle(EditorStyles.boldLabel); s_Badge.wordWrap = false; s_Badge.alignment = TextAnchor.MiddleCenter; s_Badge.padding = new RectOffset(6, 6, 2, 2); + s_Badge.normal.textColor = Color.white; + s_Badge.hover.textColor = Color.white; } return s_Badge; } } + + static GUIStyle s_RecordingBadge; + + internal static GUIStyle RecordingBadge + { + get + { + if (s_RecordingBadge == null) + { + s_RecordingBadge = new GUIStyle(Badge); + s_RecordingBadge.normal.textColor = new Color(1f, 0.35f, 0.35f); + s_RecordingBadge.hover.textColor = s_RecordingBadge.normal.textColor; + } + return s_RecordingBadge; + } + } } internal const float kMinZoom = 1.0f; internal const float kMaxZoom = 40.0f; + // Offered by the zoom badge's context menu. + static readonly int[] kZoomPresets = { 100, 200, 400, 800, 1600, 3200, 4000 }; + // Four notches of the wheel double the zoom. A factor rather than a fixed step, // because a step that is a sensible move at 100% is invisible at 4000%. const float kWheelDeltaPerNotch = 3.0f; const float kNotchesPerDoubling = 4.0f; const int kLeftMouseButton = 0; const int kMiddleMouseButton = 2; + const int kRightMouseButton = 1; const float kBadgeMargin = 4; // Deliberately more than a scrollbar takes. It only bounds how far the image can // be moved, and BeginScrollView clamps what it is handed, so guessing high costs @@ -69,6 +95,8 @@ internal static GUIStyle Badge const float kFramePadding = 2; float m_Zoom = kMinZoom; + // The last box drawn, less its scrollbars, for badges drawn after it. + Rect m_Visible; Vector2 m_Scroll; bool m_Panning; @@ -83,6 +111,13 @@ internal void Reset() m_Scroll = Vector2.zero; } + /// Puts back a zoom and scroll saved earlier, as across a domain reload. + internal void Restore(float zoom, Vector2 scroll) + { + m_Zoom = Mathf.Clamp(zoom, kMinZoom, kMaxZoom); + m_Scroll = scroll; + } + /// /// Draws an image of the given aspect ratio into and /// returns the box it is seen through, for laying out whatever sits beside it. @@ -102,9 +137,10 @@ internal Rect DoGUI(Rect area, float aspect, Action drawContents, Action r Mathf.Max(0, area.width - kFramePadding * 2), Mathf.Max(0, area.height - kFramePadding * 2)); - // Both before the scroll view, so neither it nor the contents see these + // All before the scroll view, so neither it nor the contents see these // events first: the live view forwards a plain wheel to the device, and the // scroll view would scroll on it. + HandleZoomMenu(area, aspect, repaint); var box = ViewBox(area, aspect, out _); HandleZoom(area, aspect, box, repaint); HandlePan(controlId, area, aspect, box, repaint); @@ -116,9 +152,11 @@ internal Rect DoGUI(Rect area, float aspect, Action drawContents, Action r drawContents(content); GUI.EndScrollView(); + m_Visible = VisibleRect(box, image); + // Outside the scroll view, or it would scroll away with the image. if (IsZoomed) - DoZoomBadgeGUI(box); + DoZoomBadgeGUI(m_Visible); return box; } @@ -148,6 +186,14 @@ Rect ViewBox(Rect area, float aspect, out Vector2 image) size.x, size.y); } + /// The box less its scrollbars, which sit inside it. + static Rect VisibleRect(Rect box, Vector2 image) + { + return new Rect(box.x, box.y, + box.width - (image.y > box.height ? kScrollbarSize : 0), + box.height - (image.x > box.width ? kScrollbarSize : 0)); + } + static Rect FitRect(Rect container, float aspect) { if (container.width <= 0 || container.height <= 0 || aspect <= 0) @@ -174,7 +220,19 @@ internal bool ZoomAt(Rect area, float aspect, Vector2 pointer, float wheelDelta) return false; var doublings = -wheelDelta / (kWheelDeltaPerNotch * kNotchesPerDoubling); - var zoom = Mathf.Clamp(m_Zoom * Mathf.Pow(2.0f, doublings), kMinZoom, kMaxZoom); + return ZoomTo(area, aspect, pointer, m_Zoom * Mathf.Pow(2.0f, doublings)); + } + + /// + /// Zooms to , keeping whatever is under + /// where it is. Returns false when nothing changed. + /// + bool ZoomTo(Rect area, float aspect, Vector2 pointer, float zoom) + { + if (area.width <= 0 || area.height <= 0) + return false; + + zoom = Mathf.Clamp(zoom, kMinZoom, kMaxZoom); if (Mathf.Approximately(zoom, m_Zoom)) return false; @@ -208,6 +266,39 @@ internal void Pan(Rect area, float aspect, Vector2 mouseDelta) ClampScroll(area, aspect); } + /// + /// Right clicking the zoom badge offers a few fixed zooms, each about the middle + /// of what is in view. + /// + void HandleZoomMenu(Rect area, float aspect, Action repaint) + { + var e = Event.current; + if (e.type != EventType.MouseDown || e.button != kRightMouseButton || !IsZoomed) + return; + // Worked out from this pass rather than read from m_Visible: the Layout pass + // before every event hands the window a dummy area. + var visible = VisibleRect(ViewBox(area, aspect, out var image), image); + if (!BadgeRect(visible, Styles.Zoom(ZoomPercent), Styles.Badge, false).Contains(e.mousePosition)) + return; + + var centre = visible.center; + void Set(float zoom) + { + if (ZoomTo(area, aspect, centre, zoom)) + repaint?.Invoke(); + } + + var menu = new GenericMenu(); + foreach (var percent in kZoomPresets) + { + var zoom = percent / 100.0f; + if (zoom <= kMaxZoom) + menu.AddItem(new GUIContent($"{percent}%"), ZoomPercent == percent, () => Set(zoom)); + } + menu.ShowAsContext(); + e.Use(); + } + void HandleZoom(Rect area, float aspect, Rect box, Action repaint) { var e = Event.current; @@ -298,12 +389,29 @@ void ClampScroll(Rect area, float aspect) void DoZoomBadgeGUI(Rect area) { - var content = Styles.Zoom(ZoomPercent); - var size = Styles.Badge.CalcSize(content); - var rect = new Rect(area.x + kBadgeMargin, area.y + kBadgeMargin, - Mathf.Min(Mathf.Ceil(size.x), area.width), - Mathf.Min(Mathf.Ceil(size.y), area.height)); - GUI.Label(rect, content, Styles.Badge); + DoBadgeGUI(area, Styles.Zoom(ZoomPercent), Styles.Badge, false); + } + + /// How long the recording has been running, in the top right corner of the last box drawn. + internal void DoRecordingBadgeGUI(TimeSpan elapsed) + { + var text = $"● REC {(int)elapsed.TotalMinutes}:{elapsed.Seconds:00}"; + DoBadgeGUI(m_Visible, new GUIContent(text), Styles.RecordingBadge, true); + } + + static void DoBadgeGUI(Rect area, GUIContent content, GUIStyle style, bool right) + { + var rect = BadgeRect(area, content, style, right); + EditorGUI.DrawRect(rect, Styles.BadgeBackground); + GUI.Label(rect, content, style); + } + + static Rect BadgeRect(Rect area, GUIContent content, GUIStyle style, bool right) + { + var size = style.CalcSize(content); + var width = Mathf.Min(Mathf.Ceil(size.x), area.width); + var x = right ? area.xMax - kBadgeMargin - width : area.x + kBadgeMargin; + return new Rect(x, area.y + kBadgeMargin, width, Mathf.Min(Mathf.Ceil(size.y), area.height)); } } } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs index dd21b8e5..4da6574f 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatLiveStream.cs @@ -83,6 +83,9 @@ internal enum KeyAction : byte readonly AndroidLogcatImageViewer m_Viewer = new AndroidLogcatImageViewer(); + /// The view's zoom and scroll, which the window keeps across a domain reload. + internal AndroidLogcatImageViewer Viewer => m_Viewer; + static string m_ServerJarPath; internal static string GetServerJarPath() @@ -225,7 +228,6 @@ static class Styles new GUIContent("270°", "Lock the device a quarter turn clockwise, top edge to the right") }; - internal static readonly GUIContent DeveloperMode = new GUIContent("Developer Mode"); internal static readonly GUIContent Socket = new GUIContent("Socket", "Abstract unix socket the on-device server is listening on."); internal static readonly GUIContent ForwardedPort = new GUIContent("Port", @@ -276,6 +278,10 @@ static class Styles int m_ForwardedPort = -1; Thread m_ReaderThread; + // Cleanups still running. A domain reload aborts threads, and one aborted while + // waiting on the server process hangs the reload - so closing waits for them. + readonly List m_CleanupThreads = new List(); + const int kCleanupJoinMs = 10000; /// /// A button the window puts under the stream's own controls, for doing /// something with the device being watched - taking a screenshot of it, and @@ -284,12 +290,16 @@ static class Styles /// internal readonly struct CaptureAction { - internal readonly GUIContent Label; + /// + /// Asked on every repaint, so one button can say what it does next - + /// recording starts and stops from the same place. + /// + internal readonly Func Label; internal readonly Action Action; /// Asked on every repaint. Null is a button that is always enabled. internal readonly Func Enabled; - internal CaptureAction(GUIContent label, Action action, Func enabled = null) + internal CaptureAction(Func label, Action action, Func enabled = null) { Label = label; Action = action; @@ -309,6 +319,8 @@ sealed class ReaderSession // call, and a device does not start folding half way through one. FoldStates m_FoldStates = FoldStates.None; bool m_FoldStatesQueried; + // The Max Size the server was started with, which it scales each screen to. + int m_MaxSize; volatile string m_ReaderError; volatile bool m_StreamEnded; @@ -327,6 +339,7 @@ sealed class ReaderSession int m_PendingDisplayWidth; int m_PendingDisplayHeight; int m_PendingRotation; + long m_PendingPtsUs; long m_ReceivedBytes; int m_ReceivedFrames; @@ -385,6 +398,18 @@ sealed class ReaderSession internal bool IsStreaming => m_ReaderThread != null; + volatile AndroidLogcatStreamRecorder m_Recorder; + + /// How long the owner has been recording, null when it is not. + internal Func RecordingTime; + + /// Gets every frame the reader receives while recording, not just the newest. + internal AndroidLogcatStreamRecorder Recorder + { + get => m_Recorder; + set => m_Recorder = value; + } + /// /// Raised when there is something new to say about the stream: the first frame /// has arrived and named its size, that size changed under it - a rotation, a @@ -444,6 +469,12 @@ void Cleanup() return; if (IsStreaming) Shutdown(Result.Success); + foreach (var thread in m_CleanupThreads) + { + if (!thread.Join(kCleanupJoinMs)) + AndroidLogcatInternalLog.Log("Live stream cleanup did not finish in time"); + } + m_CleanupThreads.Clear(); DestroyTexture(); m_Runtime = null; } @@ -542,8 +573,9 @@ internal void StartStreaming(IAndroidLogcatDevice device, PushServer(device, jarPath); var settings = m_Runtime.Settings; + m_MaxSize = maxSize ?? settings.LiveStreamMaxSize; StartServerProcess(device, - maxSize ?? settings.LiveStreamMaxSize, + m_MaxSize, quality ?? settings.LiveStreamQuality, maxFps ?? settings.LiveStreamMaxFps, displayId); @@ -566,7 +598,7 @@ internal void StartStreaming(IAndroidLogcatDevice device, catch (Exception ex) { AndroidLogcatInternalLog.Log(ex.ToString()); - AppendError(ex.Message); + AppendError(InnermostMessage(ex)); // Nothing is streaming, so unwind whatever did get set up and report // through the callback instead of throwing into OnGUI. Shutdown(Result.Failure); @@ -649,11 +681,14 @@ void Shutdown(Result result) void CleanUpInBackground(Thread reader, Process process, IAndroidLogcatDevice device, int port, string jarPath) { - new Thread(() => CleanUp(reader, process, device, port, jarPath)) + var thread = new Thread(() => CleanUp(reader, process, device, port, jarPath)) { Name = "AndroidLogcatLiveStreamCleanup", IsBackground = true - }.Start(); + }; + m_CleanupThreads.RemoveAll(t => !t.IsAlive); + m_CleanupThreads.Add(thread); + thread.Start(); } void CleanUp(Thread reader, Process process, IAndroidLogcatDevice device, @@ -736,6 +771,7 @@ internal void ApplyPendingFrame() int size; int width, height; int displayWidth, displayHeight, rotation; + long ptsUs; lock (m_FrameLock) { @@ -748,6 +784,7 @@ internal void ApplyPendingFrame() displayWidth = m_PendingDisplayWidth; displayHeight = m_PendingDisplayHeight; rotation = m_PendingRotation; + ptsUs = m_PendingPtsUs; } if (frame != null) @@ -761,8 +798,27 @@ internal void ApplyPendingFrame() // take the whole buffer: a reused buffer is usually larger than the // frame sitting in it. bool decoded; - using (k_DecodeFrame.Auto()) - decoded = ImageConversion.LoadImage(m_Texture, new ReadOnlySpan(frame, 0, size)); + // The recorder decodes every frame already, but may not have got to this one: + // it is reused only when it is this frame, or the view would be left behind. + var recorder = m_Recorder; + var recorded = recorder != null && recorder.LatestPtsUs == ptsUs ? recorder.Latest : null; + if (recorded != null) + { + if (m_Texture.width != recorded.width || m_Texture.height != recorded.height + || m_Texture.format != recorded.format || m_Texture.mipmapCount != recorded.mipmapCount) + { + m_Texture.Reinitialize(recorded.width, recorded.height, recorded.format, recorded.mipmapCount > 1); + m_Texture.Apply(false); + } + using (k_DecodeFrame.Auto()) + Graphics.CopyTexture(recorded, m_Texture); + decoded = true; + } + else + { + using (k_DecodeFrame.Auto()) + decoded = ImageConversion.LoadImage(m_Texture, new ReadOnlySpan(frame, 0, size)); + } if (decoded) { @@ -1009,8 +1065,9 @@ void ReadFrames(ReaderSession session) while (!session.Stop) { ReadExactly(stream, header, kFrameHeaderSize); - // Bytes 0..7 are the presentation timestamp, unused: frames are - // displayed as they arrive rather than scheduled. + // Bytes 0..7 are the presentation timestamp, in microseconds. The + // view shows frames as they arrive; only a recording uses it. + var ptsUs = (long)ReadInt32BE(header, 0) << 32 | (uint)ReadInt32BE(header, 4); var width = ReadInt32BE(header, 8); var height = ReadInt32BE(header, 12); var displayWidth = ReadInt32BE(header, 16); @@ -1027,8 +1084,15 @@ void ReadFrames(ReaderSession session) if (session != m_Session) break; + m_Recorder?.Enqueue(payload, size, ptsUs, rotation, displayWidth, displayHeight); + lock (m_FrameLock) { + // Again under the lock, which a stop clears the frame under: one + // published after that would show on the next stream. + if (session != m_Session) + break; + // Only the newest frame is kept: if the Editor cannot keep up, // showing the latest screen matters more than showing every // frame. The frame being dropped goes back to the free list @@ -1043,6 +1107,7 @@ void ReadFrames(ReaderSession session) m_PendingDisplayWidth = displayWidth; m_PendingDisplayHeight = displayHeight; m_PendingRotation = rotation; + m_PendingPtsUs = ptsUs; m_ReceivedBytes += size; m_ReceivedFrames++; } @@ -1053,7 +1118,7 @@ void ReadFrames(ReaderSession session) // A read failing after Stop was requested is just the connection we // closed ourselves. if (!session.Stop && session == m_Session) - m_ReaderError = ex.Message; + m_ReaderError = InnermostMessage(ex); } finally { @@ -1493,7 +1558,12 @@ void DoStatusGUI(Rect rc, IAndroidLogcatDevice selectedDevice) /// void DoErrorsGUI(Rect rc, IAndroidLogcatDevice selectedDevice) { - var message = new GUIContent(m_Errors.ToString(), + // Reconnect is disabled without a device, and the error is what is drawn + // instead of the no-device message, so it has to say so itself. + var text = selectedDevice == null + ? m_Errors.ToString() + Environment.NewLine + Styles.NoDevice + : m_Errors.ToString(); + var message = new GUIContent(text, EditorGUIUtility.IconContent("console.erroricon").image); var buttonHeight = EditorGUIUtility.singleLineHeight; @@ -1567,6 +1637,10 @@ void DoStreamGUI(Rect rc, int controlId, Action repaint, IReadOnlyList 0) DoStatsGUI(AndroidLogcatStatsColumn.RectBeside(rc, imageBox), captureActions); } @@ -1577,14 +1651,49 @@ void DoStreamGUI(Rect rc, int controlId, Action repaint, IReadOnlyList - internal int ViewQuarterTurns + internal int ViewQuarterTurns => QuarterTurnsFor(m_DisplayRotation); + + /// The device rotation the frame on screen came at. + internal int DisplayRotation => m_DisplayRotation; + + /// + /// The longest side of any frame the device can send: turning keeps it, and a + /// foldable's other screen can be bigger. A recording is a square of this, so + /// rotating or unfolding does not shrink the picture. 0 before the first frame. + /// + internal int LongestFrameSide() { - get + var longest = Math.Max(m_FrameWidth, m_FrameHeight); + if (longest == 0 || m_Device == null) + return longest; + + if (!m_FoldStatesQueried) { - if (m_RequestedRotation == AndroidDeviceRotation.Auto) - return 0; - return (((int)m_RequestedRotation - m_DisplayRotation) % 4 + 4) % 4; + m_FoldStatesQueried = true; + m_FoldStates = m_Device.QueryFoldStates(); } + if (!m_FoldStates.Supported) + return longest; + + foreach (var size in m_Device.QueryPhysicalDisplaySizes()) + longest = Math.Max(longest, LimitedSide(Math.Max(size.x, size.y))); + return longest; + } + + /// A screen's longest side as the server sends it: capped at Max Size, a multiple of 8. + int LimitedSide(int side) + { + if (m_MaxSize > 0 && side > m_MaxSize) + side = m_MaxSize; + return Math.Max(8, side & ~7); + } + + /// The view's turn for a frame the device sent at . + internal int QuarterTurnsFor(int displayRotation) + { + if (m_RequestedRotation == AndroidDeviceRotation.Auto) + return 0; + return (((int)m_RequestedRotation - displayRotation) % 4 + 4) % 4; } /// A size the way the view is showing it, which a turn transposes. @@ -1815,7 +1924,7 @@ void DoCaptureActionsGUI(Rect rc, ref float y, IReadOnlyList capt var enabled = capture.Action != null && (capture.Enabled == null || capture.Enabled()); EditorGUI.BeginDisabledGroup(!enabled); if (GUI.Button(new Rect(rc.x, y, ButtonRowWidth(rc), height), - capture.Label, EditorStyles.miniButton)) + capture.Label(), EditorStyles.miniButton)) { capture.Action(); } @@ -2056,6 +2165,11 @@ void SendTouchAt(TouchAction action, Rect videoRect, Vector2 mousePosition) { var position = PositionOnScreen(videoRect, mousePosition); SendTouchMessage(action, position.x, position.y); + // As the view shows it, which is how the recording is turned too. + if (m_Runtime?.Settings?.RecordTouches ?? false) + m_Recorder?.NoteTouch(action, + Mathf.Clamp01((mousePosition.x - videoRect.x) / videoRect.width), + Mathf.Clamp01((mousePosition.y - videoRect.y) / videoRect.height)); } void SendScrollAt(Rect videoRect, Vector2 mousePosition, Vector2 delta) @@ -2444,12 +2558,9 @@ void DoDebuggingGUI(Rect rc, float labelWidth, ref float y) var height = EditorGUIUtility.singleLineHeight; y += kNavigationSpacing; - if (y + height > rc.yMax) + if (!AndroidLogcatStatsColumn.DeveloperModeGUI(rc, ref y)) return; - GUI.Label(new Rect(rc.x, y, rc.width, height), Styles.DeveloperMode, EditorStyles.miniBoldLabel); - y += height; - AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.Socket, string.IsNullOrEmpty(m_SocketName) ? "-" : m_SocketName, m_SocketName); AndroidLogcatStatsColumn.Row(rc, labelWidth, ref y, Styles.ForwardedPort, diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs index b7accd12..622632f3 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenCaptureWindow.cs @@ -12,14 +12,6 @@ internal class AndroidLogcatScreenCaptureWindow : EditorWindow { class Styles { - // Note: Info acquired from adb shell screenrecord --help - public static readonly GUIContent TimeLimit = new GUIContent("Time Limit", "Toggle to override time limit (in seconds), by default - time limit is 180 seconds."); - public static readonly GUIContent VideoSize = new GUIContent("Video Size", "Toggle to override video size, by default - device's main display resolution is used."); - public static readonly GUIContent BitRate = new GUIContent("Bit Rate", "Toggle to overide bit rate (in Kbps), the default is 2000Kbps."); - public static readonly GUIContent DisplayId = new GUIContent("Display Id", "Toggle to overide the display to record, the default is primary display, enter 'adb shell dumpsys SurfaceFlinger--display - id' in the terminal for valid display IDs. If empty string is provided primary display will be used."); - public static readonly GUIContent ShowInfo = new GUIContent("Show Info", "Display video information."); - public static readonly GUIContent Open = new GUIContent("Open", "Open the recorded video."); - public static readonly GUIContent SaveAs = new GUIContent("Save As", "Save the recorded video as a file on your computer."); static readonly GUIContent kMore = EditorGUIUtility.IconContent("_Menu"); public static readonly GUIContent Advanced = kMore != null && kMore.image != null ? new GUIContent(kMore.image) @@ -29,13 +21,11 @@ class Styles "Capture the device screen and add it to the list. The screenshot comes from the device " + "rather than from the stream, so it is full resolution whatever the stream is scaled to. " + "Shortcut: Ctrl+Shift+S, Cmd+Shift+S on macOS, while this view is showing."); - public static readonly GUIContent CaptureVideo = new GUIContent("Capture", "Record the video from the android device, click Stop afterwards to stop the recording."); - public static readonly GUIContent StopVideo = new GUIContent("Stop", "Stop the recording."); - } - internal enum Mode - { - Screenshot, - Video + public static readonly GUIContent TakeRecording = new GUIContent("Take Recording", + "Record the live view, at the stream's size, and add the recording to the list when " + + "it stops. Leaving the live view stops it."); + public static readonly GUIContent StopRecording = new GUIContent("Stop Recording", + "Stop recording and add the recording to the list."); } private AndroidLogcatRuntimeBase m_Runtime; @@ -43,7 +33,6 @@ internal enum Mode private AndroidLogcatCaptureScreenshot m_CaptureScreenshot; private AndroidLogcatCaptureVideo m_CaptureVideo; - private AndroidLogcatVideoPlayer m_VideoPlayer; private AndroidLogcatLiveStream m_LiveStream; private AndroidLogcatDeviceSelection m_DeviceSelection; @@ -52,33 +41,21 @@ internal enum Mode private AndroidLogcatCaptureList m_CaptureList; private AndroidLogcatStatusBar m_StatusBar; - // Recording the screen will join this. + // So a domain reload keeps what was selected, rather than opening on the live view. + [SerializeField] + private AndroidLogcatCaptureList.Selection m_Selection; + private AndroidLogcatLiveStream.CaptureAction[] m_CaptureActions; - // Fixed, so that what follows it does not move when the mode changes. - const float kModeDropdownWidth = 90; - const float kAdvancedMenuWidth = 26; + private int m_RecordingSecondsShown = -1; - private bool IsCapturing - { - get - { - var mode = m_Runtime.UserSettings.CaptureSettings.Mode; - switch (mode) - { - case Mode.Screenshot: return m_CaptureScreenshot.IsCapturing; - case Mode.Video: return m_CaptureVideo.IsRecording; - default: - throw new NotImplementedException(mode.ToString()); - } - } - } + // Null without Android support too, as OnEnable then leaves m_CaptureVideo unset. + private TimeSpan? RecordingTime => m_CaptureVideo != null && m_CaptureVideo.IsRecording ? m_CaptureVideo.Elapsed : (TimeSpan?)null; - /// - /// The recording of the selected device. Only video has one: a screenshot is - /// opened and saved from its own row in the list. - /// - private string VideoPath => m_CaptureVideo.GetVideoPath(m_DeviceSelection.SelectedDevice); + const float kAdvancedMenuWidth = 26; + + /// Anything the device is doing for this window, for the progress icon. + private bool IsCapturing => m_CaptureScreenshot.IsCapturing || m_CaptureVideo.IsRecording; // Alongside the Logcat window's own entry, and reachable without opening that // window first - the Screen Capture window is useful on its own. A device with @@ -102,8 +79,9 @@ private void OnEnable() m_CaptureVideo = m_Runtime.CaptureVideo; m_LiveStream = m_Runtime.LiveStream; m_LiveStream.StreamChanged += ReportStream; - m_VideoPlayer = new AndroidLogcatVideoPlayer(); + m_LiveStream.RecordingTime = () => RecordingTime; m_CaptureList = new AndroidLogcatCaptureList(m_Runtime, Repaint); + m_CaptureList.RestoreSelection(m_Selection); // Nothing here connects to anything, so the bar carries the message alone. m_StatusBar = new AndroidLogcatStatusBar() { ShowConnection = false }; @@ -111,16 +89,15 @@ private void OnEnable() // appear. Each says for itself when it can run. m_CaptureActions = new[] { - new AndroidLogcatLiveStream.CaptureAction(Styles.TakeScreenshot, QueueScreenCapture, - () => CanCaptureScreenshot) + new AndroidLogcatLiveStream.CaptureAction(() => Styles.TakeScreenshot, + QueueScreenCapture, () => CanCaptureScreenshot), + // One button that starts and stops: a recording is running or it is + // not, and two buttons would leave one of them dead most of the time. + new AndroidLogcatLiveStream.CaptureAction( + () => m_CaptureVideo.IsRecording ? Styles.StopRecording : Styles.TakeRecording, + ToggleRecording, () => CanRecord) }; - // Settings saved while the removed LiveStream mode was selected still hold - // its value, which is now out of range and would throw in the switches above. - var captureSettings = m_Runtime.UserSettings.CaptureSettings; - if (!Enum.IsDefined(typeof(Mode), captureSettings.Mode)) - captureSettings.Mode = Mode.Screenshot; - m_Runtime.DeviceQuery.UpdateConnectedDevicesList(true); } private void ReloadCaptureAssetsIfNeeded(IAndroidLogcatDevice device) @@ -130,9 +107,7 @@ private void ReloadCaptureAssetsIfNeeded(IAndroidLogcatDevice device) m_LastDeviceUsedForAssets = device; - m_VideoPlayer.Play(m_CaptureVideo.GetVideoPath(device)); - - // The screenshots are not tied to a device, so losing one keeps the view. + // The captures are not tied to a device, so losing one keeps the view. if (string.IsNullOrEmpty(m_Runtime.CaptureScreenshot.SelectedImagePath)) m_Runtime.CaptureScreenshot.LoadImage(m_Runtime.CaptureScreenshot.GetLatestImagePath(device)); @@ -141,20 +116,26 @@ private void ReloadCaptureAssetsIfNeeded(IAndroidLogcatDevice device) private void OnDisable() { - // The live stream is owned by the runtime, so it would otherwise keep - // mirroring the device after the window that was showing it is gone - and - // keep reporting to a status bar that is gone with it. + // The live stream and the recorder are owned by the runtime, so they would + // otherwise carry on after the window that was showing them is gone - and + // keep reporting to a status bar that is gone with it. Dropping the + // selection ends both. + if (m_CaptureList != null) + m_Selection = m_CaptureList.SaveSelection(); m_CaptureList?.Deselect(); // Unsubscribed but kept, since OnDestroy still has undoing to do. if (m_LiveStream != null) - m_LiveStream.StreamChanged -= ReportStream; - - if (m_VideoPlayer != null) { - m_VideoPlayer.Dispose(); - m_VideoPlayer = null; + m_LiveStream.StreamChanged -= ReportStream; + m_LiveStream.RecordingTime = null; } + + // Runs twice on a domain reload, from the runtime closing and then from Unity, + // and the second pass must not save the selection the first one dropped. + m_CaptureList?.Dispose(); + m_CaptureList = null; + if (!AndroidBridge.AndroidExtensionsInstalled) return; @@ -173,6 +154,34 @@ private void OnDisable() m_CaptureList != null && m_CaptureList.LiveSelected && m_DeviceSelection.SelectedDevice != null && !m_CaptureScreenshot.IsCapturing; + /// + /// Whether a recording can be started or stopped. Stopping is always allowed + /// once one is running, whatever happened to the device since. + /// + private bool CanRecord => + m_CaptureVideo.IsRecording || (m_LiveStream.IsStreaming && m_DeviceSelection.SelectedDevice != null); + + private void ToggleRecording() + { + if (m_CaptureVideo.IsRecording) + { + m_CaptureVideo.StopRecording(); + return; + } + + // Whatever the bar said about the last capture is about to be out of date. + m_StatusBar.Message = string.Empty; + try + { + m_CaptureVideo.StartRecording(m_DeviceSelection.SelectedDevice, OnVideoCompleted); + } + catch (Exception ex) + { + m_StatusBar.Message = "Recording failed. See the Console for details."; + Debug.LogError($"Recording failed to start.\n{ex.Message}"); + } + } + private void QueueScreenCapture() { // Whatever the bar said about the last one is about to be out of date. @@ -253,11 +262,9 @@ static void CaptureScreenshotShortcut(ShortcutArguments args) void CaptureScreenshotFromShortcut() { - // The button's own conditions, plus the mode: Video mode has no live view. + // The same conditions the button draws itself with. if (m_Runtime == null || m_DeviceSelection == null) return; - if (m_Runtime.UserSettings.CaptureSettings.Mode != Mode.Screenshot) - return; if (!CanCaptureScreenshot) return; @@ -286,29 +293,27 @@ void OnScreenshotCompleted() void OnVideoCompleted(AndroidLogcatCaptureVideo.Result result, string videoPath) { - ReportSaved("Video", result == AndroidLogcatCaptureVideo.Result.Success ? videoPath : null); - - if (result == AndroidLogcatCaptureVideo.Result.Success) - m_VideoPlayer.Play(videoPath); - Repaint(); - } - - void DoModeGUI() - { - var settings = m_Runtime.UserSettings.CaptureSettings; - var mode = (Mode)EditorGUILayout.EnumPopup(settings.Mode, AndroidLogcatStyles.toolbarPopup, - GUILayout.Width(kModeDropdownWidth)); - if (mode == settings.Mode) - return; + var recorded = result == AndroidLogcatCaptureVideo.Result.Success; + ReportSaved("Recording", recorded ? videoPath : null); + if (!recorded) + { + // The bar has room for one line; what the device said goes to the Console. + m_StatusBar.Message = "Recording failed. See the Console for details."; + Debug.LogError($"Recording failed.\n{m_CaptureVideo.Errors}"); + } + else if (!string.IsNullOrEmpty(m_CaptureVideo.Errors)) + { + // Kept, but shorter than it ran for. + Debug.LogWarning($"Recording stopped early, saved up to where it failed.\n{m_CaptureVideo.Errors}"); + } - settings.Mode = mode; + // The list is counted from a cached scan, which knows nothing of a file + // the recorder has just written. + m_CaptureScreenshot.InvalidateScreenshots(); + if (recorded) + m_CaptureList?.Flash(videoPath); - // The list, and with it the Live row, is only drawn in Screenshot mode. - // Leaving that mode has to stop the stream, or the server carries on - // mirroring the device's display for a window that no longer shows it - - // and Video mode would happily start a recording alongside it. - if (mode != Mode.Screenshot) - m_CaptureList.Deselect(); + Repaint(); } /// Closed for good: a domain reload gets OnDisable and comes back. @@ -327,6 +332,16 @@ void Update() { if (m_LiveStream != null && m_LiveStream.HasPendingFrame) Repaint(); + if (m_CaptureList != null && m_CaptureList.NeedsRepaint) + Repaint(); + // A still screen sends no frames, so nothing else would move the clock. + var recording = RecordingTime; + var seconds = recording.HasValue ? (int)recording.Value.TotalSeconds : -1; + if (seconds != m_RecordingSecondsShown) + { + m_RecordingSecondsShown = seconds; + Repaint(); + } } /// @@ -362,13 +377,6 @@ void OnGUI() GUILayout.Space(5); DoPreviewGUI(); - // Video mode's settings take only the height they need, so the bar would - // sit under the last control rather than at the bottom of the window. - // Screenshot mode claims what is left for the list and the preview, and - // must not be made to share it. - if (m_Runtime.UserSettings.CaptureSettings.Mode == Mode.Video) - GUILayout.FlexibleSpace(); - m_StatusBar?.DoGUI(); EditorGUILayout.EndVertical(); @@ -381,21 +389,14 @@ private void DoToolbarGUI() DoProgressGUI(); m_DeviceSelection.DoGUI(); - DoModeGUI(); - DoCaptureGUI(); - - // Remove this once Live view is reimplemented in Video mode, or the button is moved to the video player. - if (m_Runtime.UserSettings.CaptureSettings.Mode == Mode.Video) - { - DoOpenGUI(); - DoSaveAsGUI(); - } - GUILayout.FlexibleSpace(); DoAdvancedMenuGUI(); EditorGUILayout.EndHorizontal(); + // The line along the bottom of the toolbar is drawn by the controls, not by + // the toolbar - its own background is shorter than this row - so it stops + // wherever the row is empty. One line across the row closes it. var toolbarRect = GUILayoutUtility.GetLastRect(); if (Event.current.type == EventType.Repaint) { @@ -428,81 +429,6 @@ private void DoProgressGUI() Repaint(); } - private void DoCaptureGUI() - { - EditorGUI.BeginDisabledGroup(m_DeviceSelection.SelectedDevice == null); - switch (m_Runtime.UserSettings.CaptureSettings.Mode) - { - case Mode.Screenshot: - // Taking a screenshot lives in the live view, beside the screen it - // captures - see DoScreenshotGUI. - break; - case Mode.Video: - if (m_CaptureVideo.IsRecording) - { - if (GUILayout.Button(Styles.StopVideo, AndroidLogcatStyles.toolbarButton)) - { - m_CaptureVideo.StopRecording(); - } - } - else - { - if (GUILayout.Button(Styles.CaptureVideo, AndroidLogcatStyles.toolbarButton)) - { - TimeSpan? timeLimit = null; - uint? videoSizeX = null; - uint? videoSizeY = null; - ulong? bitRate = null; - string displayId = null; - var vs = m_Runtime.UserSettings.CaptureVideoSettings; - - if (vs.TimeLimitEnabled) - { - timeLimit = TimeSpan.FromSeconds(vs.TimeLimit); - } - if (vs.VideoSizeEnabled) - { - videoSizeX = vs.VideoSizeX; - videoSizeY = vs.VideoSizeY; - } - - if (vs.BitRateEnabled) - bitRate = vs.BitRateK * 1000; - if (vs.DisplayIdEnabled && !string.IsNullOrEmpty(vs.DisplayId)) - displayId = vs.DisplayId; - - m_StatusBar.Message = string.Empty; - m_CaptureVideo.StartRecording(m_DeviceSelection.SelectedDevice, OnVideoCompleted, timeLimit, videoSizeX, videoSizeY, bitRate, displayId); - } - } - break; - } - EditorGUI.EndDisabledGroup(); - } - - private void DoOpenGUI() - { - EditorGUI.BeginDisabledGroup(!File.Exists(VideoPath)); - if (GUILayout.Button(Styles.Open, AndroidLogcatStyles.toolbarButton)) - AndroidLogcatUtilities.OpenFile(VideoPath); - EditorGUI.EndDisabledGroup(); - } - - private void DoSaveAsGUI() - { - EditorGUI.BeginDisabledGroup(!File.Exists(VideoPath)); - if (GUILayout.Button(Styles.SaveAs, AndroidLogcatStyles.toolbarButton)) - { - var settings = m_Runtime.UserSettings.CaptureSettings; - settings.SaveFileAs(settings.Mode, VideoPath, "Save Screen Capture"); - } - EditorGUI.EndDisabledGroup(); - } - - /// - /// The list of saved screenshots on the left, the selected one on the right, a - /// draggable splitter between them. - /// private void DoScreenshotGUI(Rect rc) { // Drawn with or without a device: these are files on this machine, and @@ -521,122 +447,20 @@ private void DoScreenshotGUI(Rect rc) else if (!m_CaptureList.DoPreviewGUI(imageRect)) { var message = m_DeviceSelection.SelectedDevice == null - ? "No screenshot to show. Select one from the list." - : "No screenshot to show. Select Capture to take one."; + ? "No capture to show. Select one from the list." + : "No capture to show. Select Live, then Take Screenshot or Take Recording."; EditorGUI.HelpBox(imageRect, message, MessageType.Info); } } private void DoPreviewGUI() { - switch (m_Runtime.UserSettings.CaptureSettings.Mode) - { - case Mode.Screenshot: - { - // Claimed from the layout rather than offset by a hardcoded - // toolbar height, which left a gap when the two disagreed. - var rc = GUILayoutUtility.GetRect(0, 0, - GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); - DoScreenshotGUI(rc); - } - break; - case Mode.Video: - // Unlike the saved screenshots, a recording belongs to the device it - // was taken from and is kept per device, so there is nothing to show - // while none is selected. - if (m_DeviceSelection.SelectedDevice == null) - { - EditorGUILayout.HelpBox( - "No device selected. Connect a device, then select it from the device list.", - MessageType.Info); - break; - } - - if (Unsupported.IsDeveloperMode()) - m_CaptureVideo.DoDebuggingGUI(); - DoVideoSettingsGUI(); - GUILayout.Space(5); - if (IsCapturing) - { - EditorGUILayout.HelpBox($"Recording{new String('.', (int)(Time.realtimeSinceStartup * 3) % 4 + 1)}\nClick Stop to stop the recording.", MessageType.Info); - break; - } - if (m_CaptureVideo.Errors.Length > 0) - { - DoVideoErrorsGUI(); - } - else - { - m_VideoPlayer.DoGUI(position); - if (m_VideoPlayer.IsPlaying()) - Repaint(); - } - break; - default: - break; - } - } - - void DoVideoSettingsGUI() - { - var rs = m_Runtime.UserSettings.CaptureVideoSettings; - var width = 100; - EditorGUILayout.LabelField("Toggle to override recorder settings", EditorStyles.boldLabel); - - // Time Limit - EditorGUILayout.BeginHorizontal(); - rs.TimeLimitEnabled = GUILayout.Toggle(rs.TimeLimitEnabled, Styles.TimeLimit, AndroidLogcatStyles.toolbarButton, GUILayout.Width(width)); - EditorGUI.BeginDisabledGroup(!rs.TimeLimitEnabled); - rs.TimeLimit = (uint)EditorGUILayout.IntSlider((int)rs.TimeLimit, 1, 180); - EditorGUI.EndDisabledGroup(); - EditorGUILayout.EndHorizontal(); - - // Video Size - EditorGUILayout.BeginHorizontal(); - rs.VideoSizeEnabled = GUILayout.Toggle(rs.VideoSizeEnabled, Styles.VideoSize, AndroidLogcatStyles.toolbarButton, GUILayout.Width(width)); - EditorGUI.BeginDisabledGroup(!rs.VideoSizeEnabled); - rs.VideoSizeX = (uint)EditorGUILayout.IntSlider((int)rs.VideoSizeX, 100, 7680); - rs.VideoSizeY = (uint)EditorGUILayout.IntSlider((int)rs.VideoSizeY, 100, 7680); - EditorGUI.EndDisabledGroup(); - EditorGUILayout.EndHorizontal(); - - // Bit Rate - EditorGUILayout.BeginHorizontal(); - rs.BitRateEnabled = GUILayout.Toggle(rs.BitRateEnabled, Styles.BitRate, AndroidLogcatStyles.toolbarButton, GUILayout.Width(width)); - EditorGUI.BeginDisabledGroup(!rs.BitRateEnabled); - rs.BitRateK = Math.Max(1, (uint)EditorGUILayout.IntField(GUIContent.none, (int)rs.BitRateK)); - EditorGUI.EndDisabledGroup(); - EditorGUILayout.EndHorizontal(); - - // Display Id - EditorGUILayout.BeginHorizontal(); - rs.DisplayIdEnabled = GUILayout.Toggle(rs.DisplayIdEnabled, Styles.DisplayId, AndroidLogcatStyles.toolbarButton, GUILayout.Width(width)); - EditorGUI.BeginDisabledGroup(!rs.DisplayIdEnabled); - Color? oldColor = null; - if (rs.DisplayIdEnabled && string.IsNullOrEmpty(rs.DisplayId)) - { - oldColor = GUI.color; - GUI.color = Color.red; - } - - rs.DisplayId = EditorGUILayout.TextField(GUIContent.none, rs.DisplayId); - - if (oldColor != null) - GUI.color = (Color)oldColor; - - EditorGUI.EndDisabledGroup(); - EditorGUILayout.EndHorizontal(); + // Claimed from the layout rather than offset by a hardcoded toolbar + // height, which left a gap when the two disagreed. + var rc = GUILayoutUtility.GetRect(0, 0, + GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + DoScreenshotGUI(rc); } - void DoVideoErrorsGUI() - { - var boxRect = GUILayoutUtility.GetLastRect(); - var oldColor = GUI.color; - GUI.color = Color.grey; - GUI.Box(new Rect(0, boxRect.y + boxRect.height, Screen.width, Screen.height), GUIContent.none); - GUI.color = oldColor; - EditorGUILayout.Space(20); - EditorGUILayout.HelpBox(m_CaptureVideo.Errors, MessageType.Error); - } } } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs index 2fd7290d..f2aca159 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatScreenshotInfo.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using UnityEngine; @@ -30,6 +31,60 @@ internal class AndroidLogcatScreenshotInfo public string abi; public int displayWidth; public int displayHeight; + // What changes over a recording, in time order. Only a recording has these. + public FrameChange[] frameChanges; + + /// + /// What changed seconds into a recording. Each kind of change is + /// a field of its own, unset where that kind did not change. + /// + [Serializable] + internal class FrameChange + { + public double time; + public DisplayInfo display = new DisplayInfo(); + public ContentInfo content = new ContentInfo(); + public List touches = new List(); + } + + /// + /// A touch sent from the Editor, at a position on the picture from 0 to 1, top left + /// first - so it lands on the content rect whatever size the video is. + /// + [Serializable] + internal class TouchInfo + { + // Down, Move, Up or Cancel. + public string action; + public float x; + public float y; + } + + /// + /// Where the picture sits in the video, in its pixels. The rest is black: the video + /// keeps one size while the picture is turned, folded or resized inside it. + /// + [Serializable] + internal class ContentInfo + { + public int x; + public int y; + public int width; + public int height; + + internal bool IsSet => width > 0 && height > 0; + } + + /// The device's display, which folding or turning it changes. + [Serializable] + internal class DisplayInfo + { + public int width; + public int height; + + // JsonUtility writes a nested class even when it is empty, so empty means unset. + internal bool IsSet => width > 0 && height > 0; + } internal static string PathFor(string imagePath) { diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs index 062a72bb..9ed78b5d 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettings.cs @@ -23,7 +23,10 @@ internal class AndroidLogcatSettings // hand edited settings file is held to. internal static readonly SettingsRange kLiveStreamMaxSize = new SettingsRange(1024, 256, 2048); internal static readonly SettingsRange kLiveStreamQuality = new SettingsRange(70, 1, 100); - internal static readonly SettingsRange kLiveStreamMaxFps = new SettingsRange(30, 1, 120); + internal static readonly SettingsRange kLiveStreamMaxFps = new SettingsRange(30, 1, 60); + + // Minutes a recording runs before it stops on its own. + internal static readonly SettingsRange kRecordingTimeLimit = new SettingsRange(10, 1, 60); // Where captures go unless told otherwise, relative to the project. Library is // local to the machine and outside any build, which suits output that is cheap @@ -84,6 +87,15 @@ internal class AndroidLogcatSettings [SerializeField] private int m_LiveStreamMaxFps; + [SerializeField] + private int m_RecordingTimeLimit; + + [SerializeField] + private bool m_RecordingSquareVideo; + + [SerializeField] + private bool m_RecordTouches; + internal int MemoryRequestIntervalMS { set @@ -176,6 +188,15 @@ internal string CaptureOutputDirectory } } + /// Assigns and reports the change, for the settings that just store a value. + void Set(ref T field, T value) + { + if (EqualityComparer.Default.Equals(field, value)) + return; + field = value; + InvokeOnSettingsChanged(); + } + internal int LiveStreamMaxSize { set @@ -213,6 +234,61 @@ internal int LiveStreamQuality /// Frame rate cap of the live stream. A mirrored display only produces a frame /// when the screen changes, so this is a ceiling rather than a rate. /// + /// In minutes. + internal int RecordingTimeLimit + { + set + { + var corrected = kRecordingTimeLimit.Clamp(value); + if (m_RecordingTimeLimit == corrected) + return; + m_RecordingTimeLimit = corrected; + InvokeOnSettingsChanged(); + } + get + { + return m_RecordingTimeLimit; + } + } + + /// + /// Whether a recording is a square of the stream's longest side, so turning the + /// device or unfolding it keeps the picture at full size. + /// + internal bool RecordingSquareVideo + { + set + { + if (m_RecordingSquareVideo == value) + return; + m_RecordingSquareVideo = value; + InvokeOnSettingsChanged(); + } + get + { + return m_RecordingSquareVideo; + } + } + + /// + /// Whether touches sent from the live view are saved with a recording, for its + /// player to draw. + /// + internal bool RecordTouches + { + set + { + if (m_RecordTouches == value) + return; + m_RecordTouches = value; + InvokeOnSettingsChanged(); + } + get + { + return m_RecordTouches; + } + } + internal int LiveStreamMaxFps { set @@ -310,6 +386,9 @@ internal void Reset() m_MessageFontSize = 11; m_MaxExitedPackagesToShow = 4; m_CaptureOutputDirectory = kDefaultCaptureOutputDirectory; + m_RecordingTimeLimit = kRecordingTimeLimit.Default; + m_RecordingSquareVideo = true; + m_RecordTouches = true; ResetLiveStreamFields(); if (Enum.GetValues(typeof(Priority)).Length != 6) throw new Exception("Unexpected length of Priority enum."); @@ -446,6 +525,7 @@ private void Validate() m_LiveStreamMaxSize = kLiveStreamMaxSize.OrDefault(m_LiveStreamMaxSize); m_LiveStreamQuality = kLiveStreamQuality.OrDefault(m_LiveStreamQuality); m_LiveStreamMaxFps = kLiveStreamMaxFps.OrDefault(m_LiveStreamMaxFps); + m_RecordingTimeLimit = kRecordingTimeLimit.OrDefault(m_RecordingTimeLimit); } internal static AndroidLogcatSettings Load() diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs index 8aa79fe7..4eea8f8f 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatSettingsProvider.cs @@ -28,6 +28,21 @@ class Styles + "this machine and not part of a build."); public static readonly GUIContent browse = new GUIContent("Browse...", "Pick the captures folder."); + public static readonly GUIContent recordingTimeLimit = new GUIContent("Time Limit (minutes)", + "How long a recording runs before it stops on its own."); + public static readonly GUIContent recordingSquareVideo = new GUIContent("Square Video", + "Record a square video as wide and tall as the stream's longest side.\n\n" + + "Pros: turning the device or unfolding a foldable keeps the picture at full size. " + + "The Screen Capture window plays it without the black areas.\n\n" + + "Cons: other video players show black areas around the picture. Each frame has more " + + "pixels to encode, about twice as many on a portrait phone, so recording costs more " + + "Editor time and the file is larger.\n\n" + + "When off, the video is the size of the screen when recording starts, and a turned or " + + "unfolded screen is scaled down to fit inside it."); + public static readonly GUIContent recordTouches = new GUIContent("Record Touches", + "Save the clicks and drags sent from the live view with a recording, so playing it in the " + + "Screen Capture window shows where the screen was touched. Other video players do not show them."); + public static readonly GUIContent liveStreamMaxSize = new GUIContent("Max Size", "Longest side of the streamed image in pixels. The device display is scaled down to fit, which is what keeps the bandwidth and the encoding cost on the device down."); public static readonly GUIContent liveStreamQuality = new GUIContent("JPEG Quality", @@ -35,7 +50,7 @@ class Styles public static readonly GUIContent liveStreamReset = new GUIContent("Reset", $"Put Max Size, JPEG Quality and Max Frame Rate back to {AndroidLogcatSettings.kLiveStreamMaxSize.Default}, {AndroidLogcatSettings.kLiveStreamQuality.Default} and {AndroidLogcatSettings.kLiveStreamMaxFps.Default}, leaving every other setting alone."); public static readonly GUIContent liveStreamMaxFps = new GUIContent("Max Frame Rate", - "Ceiling on frames per second. A mirrored display only produces a frame when the screen changes, so an idle device sends fewer than this rather than exactly this."); + "Ceiling on frames per second, and the frame rate recordings are made at. A mirrored display only produces a frame when the screen changes, so an idle device sends fewer than this rather than exactly this."); } private AndroidLogcatRuntimeBase m_Runtime; @@ -85,15 +100,22 @@ public override void OnGUI(string searchContext) EditorGUILayout.LabelField("Capture Settings", EditorStyles.boldLabel); DoCapturesFolderGUI(settings); + GUILayout.Space(10); + EditorGUILayout.LabelField("Recording", EditorStyles.miniBoldLabel); + settings.RecordingTimeLimit = RangeSlider(Styles.recordingTimeLimit, + settings.RecordingTimeLimit, AndroidLogcatSettings.kRecordingTimeLimit); + settings.RecordingSquareVideo = EditorGUILayout.Toggle(Styles.recordingSquareVideo, settings.RecordingSquareVideo); + settings.RecordTouches = EditorGUILayout.Toggle(Styles.recordTouches, settings.RecordTouches); + GUILayout.Space(10); EditorGUILayout.LabelField("Live Stream", EditorStyles.miniBoldLabel); // Applied when a stream starts, so a stream that is already running keeps the // settings it started with until it is reconnected. - settings.LiveStreamMaxSize = LiveStreamSlider(Styles.liveStreamMaxSize, + settings.LiveStreamMaxSize = RangeSlider(Styles.liveStreamMaxSize, settings.LiveStreamMaxSize, AndroidLogcatSettings.kLiveStreamMaxSize); - settings.LiveStreamQuality = LiveStreamSlider(Styles.liveStreamQuality, + settings.LiveStreamQuality = RangeSlider(Styles.liveStreamQuality, settings.LiveStreamQuality, AndroidLogcatSettings.kLiveStreamQuality); - settings.LiveStreamMaxFps = LiveStreamSlider(Styles.liveStreamMaxFps, + settings.LiveStreamMaxFps = RangeSlider(Styles.liveStreamMaxFps, settings.LiveStreamMaxFps, AndroidLogcatSettings.kLiveStreamMaxFps); EditorGUILayout.HelpBox( @@ -190,7 +212,7 @@ static void SetCapturesFolder(AndroidLogcatSettings settings, string picked) /// A slider whose ends come from the setting's own range, so the UI cannot offer /// what the setter would clamp away. /// - static int LiveStreamSlider(GUIContent label, int value, SettingsRange range) + static int RangeSlider(GUIContent label, int value, SettingsRange range) { return EditorGUILayout.IntSlider(label, value, range.Min, range.Max); } diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs index 4453c8fc..4b8fc8d4 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStatsColumn.cs @@ -11,9 +11,14 @@ internal static class AndroidLogcatStatsColumn { const float kWidth = 190; const float kMargin = 8; + // Off the box's right edge, so the column's buttons and slider do not touch it. + const float kRightMargin = 10; internal const float kLabelWidth = 80; + static readonly GUIContent kDeveloperMode = new GUIContent("Developer Mode"); + static readonly GUIContent kReloadDomain = new GUIContent("Reload Domain", "Reload the script domain."); + /// /// The box an image and its column sit in, which is the same help box a message /// in that area is drawn in - so the view keeps its shape whether it is showing @@ -45,7 +50,7 @@ internal static float WidthFor(Rect area, string[] values) foreach (var value in values) widest = Mathf.Max(widest, Mathf.Ceil(EditorStyles.miniLabel.CalcSize(new GUIContent(value)).x)); - return Mathf.Min(Mathf.Max(kWidth, kLabelWidth + widest + kMargin), area.width * 0.4f); + return Mathf.Min(Mathf.Max(kWidth, kLabelWidth + widest + kMargin + kRightMargin), area.width * 0.4f); } /// @@ -57,7 +62,26 @@ internal static float WidthFor(Rect area, string[] values) internal static Rect RectBeside(Rect area, Rect imageBox) { return new Rect(imageBox.xMax + kMargin, area.y, - Mathf.Max(0, area.xMax - imageBox.xMax - kMargin), area.height); + Mathf.Max(0, area.xMax - imageBox.xMax - kMargin - kRightMargin), area.height); + } + + /// + /// The Developer Mode heading and Reload Domain under it, which start each view's + /// debugging details. False when there was no room for them. + /// + internal static bool DeveloperModeGUI(Rect rc, ref float y) + { + var height = EditorGUIUtility.singleLineHeight; + if (y + 2 * height > rc.yMax) + return false; + + GUI.Label(new Rect(rc.x, y, rc.width, height), kDeveloperMode, EditorStyles.miniBoldLabel); + y += height; + var reloadWidth = Mathf.Min(EditorStyles.miniButton.CalcSize(kReloadDomain).x, rc.width); + if (GUI.Button(new Rect(rc.x, y, reloadWidth, height), kReloadDomain, EditorStyles.miniButton)) + EditorUtility.RequestScriptReload(); + y += height; + return true; } internal static void Row(Rect rc, float labelWidth, ref float y, GUIContent name, string value, diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs new file mode 100644 index 00000000..4e449c17 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using UnityEditor; +using UnityEditor.Media; +using Unity.Profiling; +using Unity.Scripting.LifecycleManagement; +using UnityEngine; + +namespace Unity.Android.Logcat +{ + /// + /// Records the live stream's JPEG frames into an MP4 with MediaEncoder. Frames are + /// queued from the reader thread and encoded on EditorApplication.update, whether + /// or not the window paints. + /// + /// Constant frame rate: the recording is cut into 1/frame rate slots and every slot + /// gets exactly one frame. The device only sends a frame when the screen changes, so + /// a slot gets the newest frame that arrived in it, or a repeat of the previous one. + /// That keeps VideoPlayer's frame numbers and times exact, so the player can step and + /// seek by frame without reading the file's timing. + /// + /// + [NoAutoStaticsCleanup] + internal class AndroidLogcatStreamRecorder + { + const float kBitsPerPixel = 0.1f; + // About three seconds. Past that the Editor is not keeping up, and frames are + // dropped rather than piling up in memory. + const int kMaxQueued = 90; + // Per Editor update. The rest waits rather than stalling the Editor. + const double kBudgetMs = 8; + // How far behind now a still screen's repeats are written, so a frame still on + // its way from the device can take its slot. + const long kIdleMarginUs = 100000; + + // Found by typing "AndroidLogcat" into the Profiler's search box. + static readonly ProfilerMarker k_Update = new ProfilerMarker("AndroidLogcat.RecordUpdate"); + static readonly ProfilerMarker k_Decode = new ProfilerMarker("AndroidLogcat.RecordDecode"); + static readonly ProfilerMarker k_Compose = new ProfilerMarker("AndroidLogcat.RecordCompose"); + static readonly ProfilerMarker k_Encode = new ProfilerMarker("AndroidLogcat.RecordEncode"); + + struct QueuedFrame + { + internal byte[] Data; + internal long PtsUs; + internal long ArrivalUs; + internal int Rotation; + internal int DisplayWidth; + internal int DisplayHeight; + } + + readonly string m_Path; + // Every frame lasts exactly 1/m_FrameRate s. + readonly int m_FrameRate; + readonly object m_Lock = new object(); + readonly Queue m_Queue = new Queue(); + // The recording's own time, from Start. The device only sends a frame when the + // screen changes, so its timestamps alone cannot say when recording began. + readonly Stopwatch m_Clock = Stopwatch.StartNew(); + // The live view's turn for a frame's display rotation, which the recording follows. + readonly Func m_QuarterTurnsFor; + + MediaEncoder m_Encoder; + Texture2D m_Decoded; + // The frame for slot m_Written, not written yet: a newer one may still replace it. + Texture2D m_Canvas; + bool m_HasPending; + int m_Width; + int m_Height; + long m_Written; + // Writes of the pending frame owed before the next one can take its place. + long m_Owed; + // Decoded, waiting for m_Owed to be paid before it becomes the pending frame. + bool m_HasDecoded; + int m_DecodedTurns; + // The device rotation m_Decoded came at, to turn it again when the view does. + int m_DecodedRotation; + // The live view's texture the recording started from, until a frame is decoded. + Texture2D m_Seed; + readonly int m_Side; + readonly List m_FrameChanges = + new List(); + // The display and the picture's place in the video, as the last change set them. + int m_DisplayWidth; + int m_DisplayHeight; + RectInt m_Content; + + /// What changed over the recording, from its start. + internal IReadOnlyList FrameChanges => m_FrameChanges; + long m_FirstPtsUs = -1; + // Where the first device frame lands on the recording's clock. + long m_FirstArrivalUs; + + // Stats, logged when a recording stops. + int m_Received; + int m_Replaced; + int m_Dropped; + int m_Failed; + double m_DecodeMs; + double m_EncodeMs; + double m_WorstUpdateMs; + + internal string Path => m_Path; + internal string Error { get; private set; } = string.Empty; + + /// The newest frame decoded, which the live view shows instead of decoding it again. + internal Texture2D Latest => m_Decoded; + + /// The device timestamp of , -1 before the first. + internal long LatestPtsUs { get; private set; } = -1; + + /// + /// What the live view shows now, recorded as the first frame: a still screen + /// sends nothing until it changes. Null before the stream's first frame. + /// + /// The device rotation came at. + /// The live stream's Max Frame Rate: the most it can deliver. + /// + /// The video is a square of this, so any frame fits it turned either way. 0 sizes + /// it to the first frame instead. + /// + internal AndroidLogcatStreamRecorder(string path, int frameRate, Texture2D current, int currentRotation, + Vector2Int currentDisplaySize, Func quarterTurnsFor, int side = 0) + { + m_Path = path; + m_Side = side; + m_FrameRate = Math.Max(1, frameRate); + // After the frame rate, which turns its slot into a time. + NoteDisplaySize(currentDisplaySize.x, currentDisplaySize.y, 0); + m_QuarterTurnsFor = quarterTurnsFor; + if (current != null) + { + try + { + m_Seed = current; + m_DecodedRotation = currentRotation; + m_DecodedTurns = quarterTurnsFor(currentRotation); + CreateEncoder(current.width, current.height, m_DecodedTurns); + using (k_Compose.Auto()) + Compose(current, m_DecodedTurns); + NoteContent(current.width, current.height, m_DecodedTurns, 0); + m_HasPending = true; + } + catch (Exception ex) + { + Fail(ex); + } + } + EditorApplication.update += Update; + } + + long NowUs => m_Clock.ElapsedTicks * 1000000 / Stopwatch.Frequency; + + /// The slot a time falls in: frame number N covers N/m_FrameRate s. + long SlotAt(long us) => (long)Math.Round(us * (double)m_FrameRate / 1000000); + + /// Reader thread. Copies the frame, since the reader reuses its buffers. + internal void Enqueue(byte[] data, int size, long ptsUs, int rotation, int displayWidth, int displayHeight) + { + lock (m_Lock) + { + if (m_Queue.Count >= kMaxQueued) + { + m_Dropped++; + return; + } + } + // Outside the lock, and only once there is room. The reader is the only one + // adding, so the room is still there after. + var copy = new byte[size]; + Buffer.BlockCopy(data, 0, copy, 0, size); + lock (m_Lock) + m_Queue.Enqueue(new QueuedFrame + { + Data = copy, + PtsUs = ptsUs, + ArrivalUs = NowUs, + Rotation = rotation, + DisplayWidth = displayWidth, + DisplayHeight = displayHeight + }); + } + + void Update() + { + using (k_Update.Auto()) + Drain(kBudgetMs); + } + + void Drain(double budgetMs) + { + var watch = Stopwatch.StartNew(); + try + { + while (string.IsNullOrEmpty(Error) && watch.Elapsed.TotalMilliseconds < budgetMs && Step()) + { + } + } + catch (Exception ex) + { + Fail(ex); + } + // Not the flush at Stop, which is allowed to take as long as it needs. + if (budgetMs < double.MaxValue) + m_WorstUpdateMs = Math.Max(m_WorstUpdateMs, watch.Elapsed.TotalMilliseconds); + } + + /// One piece of work, in order: an owed write, a waiting frame, the next queued one. + bool Step() + { + if (m_Owed > 0) + { + WritePending(); + m_Owed--; + return true; + } + + if (m_HasDecoded) + { + var watch = Stopwatch.StartNew(); + using (k_Compose.Auto()) + Compose(m_Decoded, m_DecodedTurns); + m_EncodeMs += watch.Elapsed.TotalMilliseconds; + m_HasDecoded = false; + m_HasPending = true; + return true; + } + + var frame = default(QueuedFrame); + var idle = false; + lock (m_Lock) + { + if (m_Queue.Count == 0) + idle = true; + else + frame = m_Queue.Dequeue(); + } + if (!idle) + { + Decode(frame); + return true; + } + + // Asking for a rotation the app refuses sends no frame, yet the view turns at + // once - so the frame held is turned again here, as the view turns it. + var source = m_Decoded != null ? m_Decoded : m_Seed; + if (m_HasPending && source != null) + { + var turns = m_QuarterTurnsFor(m_DecodedRotation); + if (turns != m_DecodedTurns) + { + m_DecodedTurns = turns; + using (k_Compose.Auto()) + Compose(source, turns); + NoteContent(source.width, source.height, turns, m_Written); + return true; + } + } + + // A still screen sends nothing, so its repeats are written as time passes rather + // than all at once when the next frame comes, or at Stop. + if (m_HasPending && m_Written < SlotAt(NowUs - kIdleMarginUs)) + { + var before = m_Written; + WritePending(); + return m_Written > before; + } + return false; + } + + void Decode(QueuedFrame frame) + { + var watch = Stopwatch.StartNew(); + if (m_Decoded == null) + m_Decoded = new Texture2D(2, 2, TextureFormat.RGB24, true); + bool decoded; + using (k_Decode.Auto()) + decoded = ImageConversion.LoadImage(m_Decoded, new ReadOnlySpan(frame.Data)); + if (!decoded) + { + m_Failed++; + return; + } + m_DecodeMs += watch.Elapsed.TotalMilliseconds; + m_Received++; + LatestPtsUs = frame.PtsUs; + m_DecodedRotation = frame.Rotation; + m_DecodedTurns = m_QuarterTurnsFor(frame.Rotation); + + if (m_Encoder == null) + CreateEncoder(m_Decoded.width, m_Decoded.height, m_DecodedTurns); + + // Device spacing, anchored where the first device frame arrived, then rounded + // to a slot - the only place a frame's time is decided. + if (m_FirstPtsUs < 0) + { + m_FirstPtsUs = frame.PtsUs; + m_FirstArrivalUs = frame.ArrivalUs; + } + var slot = SlotAt(m_FirstArrivalUs + frame.PtsUs - m_FirstPtsUs); + + // Shown from its own slot, or from the pending one's when it replaces that. + var shownFrom = m_HasPending ? Math.Max(slot, m_Written) : m_Written; + NoteDisplaySize(frame.DisplayWidth, frame.DisplayHeight, shownFrom); + NoteContent(m_Decoded.width, m_Decoded.height, m_DecodedTurns, shownFrom); + + // The pending frame fills every slot up to this one. In its own slot, or + // an earlier one, this frame replaces it instead. + if (m_HasPending && slot > m_Written) + m_Owed = slot - m_Written; + else if (m_HasPending) + m_Replaced++; + m_HasDecoded = true; + } + + /// One slot. Written without a timestamp, so the encoder spaces it 1/m_FrameRate s on. + void WritePending() + { + var watch = Stopwatch.StartNew(); + bool added; + using (k_Encode.Auto()) + added = m_Encoder.AddFrame(m_Canvas); + if (added) + m_Written++; + else + m_Failed++; + m_EncodeMs += watch.Elapsed.TotalMilliseconds; + } + + void NoteDisplaySize(int width, int height, long slot) + { + if (width <= 0 || height <= 0) + return; + if (width == m_DisplayWidth && height == m_DisplayHeight) + return; + m_DisplayWidth = width; + m_DisplayHeight = height; + var change = ChangeAt((double)slot / m_FrameRate); + change.display.width = width; + change.display.height = height; + } + + /// + /// A touch sent from the Editor, at a position on the picture as the view shows it, + /// 0 to 1 from the top left. Android does not draw injected touches, so a player + /// draws these instead. Only the last move of a frame is kept. + /// + internal void NoteTouch(AndroidLogcatLiveStream.TouchAction action, float x, float y) + { + var change = ChangeAt((double)SlotAt(NowUs) / m_FrameRate); + var touches = change.touches; + var last = touches.Count > 0 ? touches[touches.Count - 1] : null; + var name = action.ToString(); + if (last != null && action == AndroidLogcatLiveStream.TouchAction.Move && last.action == name) + { + last.x = x; + last.y = y; + return; + } + touches.Add(new AndroidLogcatScreenshotInfo.TouchInfo { action = name, x = x, y = y }); + } + + /// Where Compose puts a frame in the video, so a player can leave out the black. + void NoteContent(int sourceWidth, int sourceHeight, int turns, long slot) + { + if (m_Width <= 0 || sourceWidth <= 0 || sourceHeight <= 0) + return; + var sideways = (turns & 1) != 0; + var turnedWidth = sideways ? sourceHeight : sourceWidth; + var turnedHeight = sideways ? sourceWidth : sourceHeight; + // As Compose fits it: scaled to touch two sides, centred. + var scale = Mathf.Min((float)m_Width / turnedWidth, (float)m_Height / turnedHeight); + var width = Mathf.RoundToInt(turnedWidth * scale); + var height = Mathf.RoundToInt(turnedHeight * scale); + var content = new RectInt((m_Width - width) / 2, (m_Height - height) / 2, width, height); + if (content.Equals(m_Content)) + return; + m_Content = content; + var change = ChangeAt((double)slot / m_FrameRate); + change.content.x = content.x; + change.content.y = content.y; + change.content.width = content.width; + change.content.height = content.height; + } + + /// + /// The change at , inserted in time order when there is none. + /// Not always last: a touch is timed when it is sent, while a frame still queued is + /// timed by its slot, which can be earlier. + /// + AndroidLogcatScreenshotInfo.FrameChange ChangeAt(double time) + { + var index = m_FrameChanges.Count; + while (index > 0 && m_FrameChanges[index - 1].time > time) + index--; + // Times are slots over the frame rate, so the same slot gives the same time exactly. + if (index > 0 && m_FrameChanges[index - 1].time == time) + return m_FrameChanges[index - 1]; + var change = new AndroidLogcatScreenshotInfo.FrameChange { time = time }; + m_FrameChanges.Insert(index, change); + return change; + } + + void Fail(Exception ex) + { + Error = ex.Message; + AndroidLogcatInternalLog.Log($"Stream recording failed: {ex}"); + } + + /// Sized to the first frame as the view shows it, turned or not. + void CreateEncoder(int width, int height, int turns) + { + var sideways = (turns & 1) != 0; + // H.264 needs even sizes. + m_Width = (m_Side > 0 ? m_Side : sideways ? height : width) & ~1; + m_Height = (m_Side > 0 ? m_Side : sideways ? width : height) & ~1; + var h264 = new H264EncoderAttributes + { + // A keyframe a second; the frames between are differences, so a repeat is nearly free. + gopSize = (uint)m_FrameRate, + // Frames stored in the order they are shown. High profile reorders them + // anyway on Windows, and VideoPlayer warns about the timestamps. + numConsecutiveBFrames = 0, + profile = VideoEncodingProfile.H264Baseline + }; + var attributes = new VideoTrackEncoderAttributes(h264) + { + frameRate = new MediaRational(m_FrameRate), + width = (uint)m_Width, + height = (uint)m_Height, + includeAlpha = false, + targetBitRate = (uint)(m_Width * m_Height * m_FrameRate * kBitsPerPixel) + }; + m_Encoder = new MediaEncoder(m_Path, attributes); + } + + /// + /// Converts a frame to RGBA32, which is all the encoder takes, turned as the live + /// view turns it and at the encoder's fixed size - centered on black when the + /// size differs, after a rotation or an odd one. + /// + void Compose(Texture2D source, int turns) + { + if (m_Canvas == null) + m_Canvas = new Texture2D(m_Width, m_Height, TextureFormat.RGBA32, false); + + var sideways = (turns & 1) != 0; + var turnedWidth = sideways ? source.height : source.width; + var turnedHeight = sideways ? source.width : source.height; + var scale = Mathf.Min((float)m_Width / turnedWidth, (float)m_Height / turnedHeight); + + var target = RenderTexture.GetTemporary(m_Width, m_Height, 0, RenderTextureFormat.ARGB32); + var previous = RenderTexture.active; + RenderTexture.active = target; + try + { + GL.Clear(true, true, Color.black); + GL.PushMatrix(); + try + { + GL.LoadPixelMatrix(0, m_Width, m_Height, 0); + // Counter-clockwise, as the live view turns it, about the canvas centre. + var centre = new Vector3(m_Width * 0.5f, m_Height * 0.5f, 0); + GL.MultMatrix(Matrix4x4.TRS(centre, Quaternion.Euler(0, 0, turns * -90f), new Vector3(scale, scale, 1))); + Graphics.DrawTexture(new Rect(-source.width * 0.5f, -source.height * 0.5f, source.width, source.height), source); + } + finally + { + GL.PopMatrix(); + } + m_Canvas.ReadPixels(new Rect(0, 0, m_Width, m_Height), 0, 0, false); + } + finally + { + // The Editor draws into whatever is left active. + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + } + } + + /// + /// Encodes what is still queued, holds the last frame until now, and closes the + /// file. Returns false when nothing was written, which leaves no file behind. + /// + internal bool Stop() + { + // Taken first, so the time the final writes take does not lengthen the recording. + var stopSlot = SlotAt(NowUs); + EditorApplication.update -= Update; + Drain(double.MaxValue); + var closed = true; + try + { + if (m_HasPending && string.IsNullOrEmpty(Error)) + { + var last = Math.Max(stopSlot, m_Written); + while (m_Written <= last && string.IsNullOrEmpty(Error)) + { + var before = m_Written; + WritePending(); + if (m_Written == before) + break; + } + } + } + catch (Exception ex) + { + Fail(ex); + } + finally + { + try + { + m_Encoder?.Dispose(); + } + catch (Exception ex) + { + closed = false; + Fail(ex); + } + m_Encoder = null; + if (m_Decoded != null) + UnityEngine.Object.DestroyImmediate(m_Decoded); + if (m_Canvas != null) + UnityEngine.Object.DestroyImmediate(m_Canvas); + m_Decoded = null; + m_Canvas = null; + m_Seed = null; + } + // Frames written before an error are still a playable file, once it is closed. + return closed && m_Written > 0; + } + + /// How the recording went, for the log. + internal string Stats() + { + var seconds = m_Written / (double)m_FrameRate; + return $"Stream recording: {m_Written} frames at {m_FrameRate} fps ({seconds:0.0} s), {m_Width}x{m_Height}, " + + $"{m_Received} received, {m_Replaced} replaced by a newer one in the same slot, " + + $"{m_Dropped} dropped (Editor behind), {m_Failed} failed. " + + $"Per received frame: decode {m_DecodeMs / Math.Max(1, m_Received):0.0} ms. " + + $"Per written frame: encode {m_EncodeMs / Math.Max(1, m_Written):0.0} ms. " + + $"Worst update {m_WorstUpdateMs:0} ms."; + } + } +} diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs.meta b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs.meta new file mode 100644 index 00000000..c303a975 --- /dev/null +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatStreamRecorder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec574685252a4049bb37f9113ca4c768 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs index 53d70da3..fed67f47 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUserSettings.cs @@ -24,73 +24,35 @@ internal class InputSettings internal ProcessInformation TargetProcess; } - [Serializable] - internal class VideoSettings - { - [SerializeField] - internal bool TimeLimitEnabled; - [SerializeField] - internal uint TimeLimit; - [SerializeField] - internal bool VideoSizeEnabled; - [SerializeField] - internal uint VideoSizeX; - [SerializeField] - internal uint VideoSizeY; - [SerializeField] - internal bool BitRateEnabled; - [SerializeField] - internal ulong BitRateK; - [SerializeField] - internal bool DisplayIdEnabled; - [SerializeField] - internal string DisplayId; - } - [Serializable] internal class ScreenCaptureSettings { - [SerializeField] - internal AndroidLogcatScreenCaptureWindow.Mode Mode; /// Width of the saved screenshot list, left of the splitter. [SerializeField] internal float CaptureListWidth; [SerializeField] - private string[] m_LastSaveLocation; + private string m_LastSaveLocation; /// /// Saves a copy of a capture wherever the user picks, starting from where - /// they picked last time for this mode, and remembering where that was. + /// they picked last time, and remembering where that was. /// - internal void SaveFileAs(AndroidLogcatScreenCaptureWindow.Mode mode, string path, string title) + internal void SaveFileAs(string path, string title) { - var directory = AndroidLogcatUtilities.SaveFileAs(path, title, GetLastSaveLocation(mode)); + var directory = AndroidLogcatUtilities.SaveFileAs(path, title, LastSaveLocation); if (directory != null) - SetLastSaveLocation(mode, directory); + LastSaveLocation = directory; } - internal void SetLastSaveLocation(AndroidLogcatScreenCaptureWindow.Mode mode, string path) + internal string LastSaveLocation { - if (m_LastSaveLocation == null || (int)mode >= m_LastSaveLocation.Length) - ResetLastSaveLocation(); - m_LastSaveLocation[(int)mode] = path; - } - - internal string GetLastSaveLocation(AndroidLogcatScreenCaptureWindow.Mode mode) - { - if (m_LastSaveLocation == null || (int)mode >= m_LastSaveLocation.Length) - ResetLastSaveLocation(); - return m_LastSaveLocation[(int)mode]; - } - - internal void ResetLastSaveLocation() - { - var length = Enum.GetValues(typeof(AndroidLogcatScreenCaptureWindow.Mode)).Length; - var defaultDirectory = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - m_LastSaveLocation = new string[length]; - for (int i = 0; i < m_LastSaveLocation.Length; i++) - m_LastSaveLocation[i] = defaultDirectory; - + get + { + if (string.IsNullOrEmpty(m_LastSaveLocation)) + m_LastSaveLocation = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return m_LastSaveLocation; + } + set { m_LastSaveLocation = value; } } } @@ -123,8 +85,6 @@ internal class QueryLayoutSettings [SerializeField] private List m_SymbolPaths; [SerializeField] - private VideoSettings m_CaptureVideoSettings; - [SerializeField] private ScreenCaptureSettings m_ScreenCaptureSettings; [SerializeField] private QueryLayoutSettings m_QueryLayoutSettings; @@ -188,7 +148,6 @@ public Priority SelectedPriority } } - public VideoSettings CaptureVideoSettings { set => m_CaptureVideoSettings = value; get => m_CaptureVideoSettings; } public ScreenCaptureSettings CaptureSettings { set => m_ScreenCaptureSettings = value; get => m_ScreenCaptureSettings; } public QueryLayoutSettings LayoutSettings { set => m_QueryLayoutSettings = value; get => m_QueryLayoutSettings; } public InputSettings DeviceInputSettings { set => m_InputSettings = value; get => m_InputSettings; } @@ -364,7 +323,6 @@ internal void Reset() m_FilterOptions = new FilterOptions(); m_AutoScroll = AutoScroll.Auto; - ResetCaptureVideoSettings(); ResetScreenCaptureSettings(); m_QueryLayoutSettings = new QueryLayoutSettings(); @@ -376,31 +334,12 @@ internal void Reset() }; } - internal void ResetCaptureVideoSettings() - { - m_CaptureVideoSettings = new VideoSettings - { - TimeLimitEnabled = false, - BitRateEnabled = false, - DisplayIdEnabled = false, - VideoSizeEnabled = false, - - TimeLimit = 180, - BitRateK = 20000, - VideoSizeX = 1280, - VideoSizeY = 720, - DisplayId = string.Empty - }; - } - internal void ResetScreenCaptureSettings() { m_ScreenCaptureSettings = new ScreenCaptureSettings { - Mode = AndroidLogcatScreenCaptureWindow.Mode.Screenshot, CaptureListWidth = AndroidLogcatCaptureList.kDefaultWidth }; - m_ScreenCaptureSettings.ResetLastSaveLocation(); } internal static AndroidLogcatUserSettings Load(string path) diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs index 58334d8c..e86085e1 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatUtilities.cs @@ -1020,15 +1020,6 @@ internal static bool ParseCrashLine(IReadOnlyList regexs, str return false; } - internal static void KillScreenRecordProcess(AndroidLogcatRuntimeBase runtime, IAndroidLogcatDevice device) - { - if (device == null) - return; - var pid = GetPidFromPackageName(runtime.Tools.ADB, device, "screenrecord"); - if (pid != -1) - KillProcesss(runtime.Tools.ADB, device, pid); - } - internal static void ShowAndroidIsNotInstalledMessage() { UnityEditor.EditorGUILayout.HelpBox("Android Logcat requires Android support to be installed.", UnityEditor.MessageType.Info); diff --git a/com.unity.mobile.android-logcat/Editor/AndroidLogcatVideoPlayer.cs b/com.unity.mobile.android-logcat/Editor/AndroidLogcatVideoPlayer.cs index c6d5937b..31e0ee4c 100644 --- a/com.unity.mobile.android-logcat/Editor/AndroidLogcatVideoPlayer.cs +++ b/com.unity.mobile.android-logcat/Editor/AndroidLogcatVideoPlayer.cs @@ -3,19 +3,151 @@ using System.IO; using System.Text; using UnityEditor; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEngine.Video; namespace Unity.Android.Logcat { + /// + /// Plays a recording in the capture preview: a VideoPlayer on a hidden GameObject, + /// rendering into a texture the caller draws, plus the controls under it - play and + /// pause, a frame step each way and a seek slider. Recordings have a constant frame + /// rate, so VideoPlayer's frame numbers are the file's own. + /// internal class AndroidLogcatVideoPlayer : IDisposable { + [NoAutoStaticsCleanup] + static class Styles + { + // The icons the video clip inspector's preview toggles between, so playing + // a recording looks the same as playing a clip. + internal static readonly GUIContent Play = IconOrText("preAudioPlayOff", "Play", "Play the recording."); + internal static readonly GUIContent Pause = IconOrText("preAudioPlayOn", "Pause", "Pause the recording."); + + internal static readonly GUIStyle FrameCounter = new GUIStyle(EditorStyles.miniLabel) + { + alignment = TextAnchor.MiddleRight + }; + + static GUIContent IconOrText(string icon, string text, string tooltip) + { + var content = EditorGUIUtility.IconContent(icon); + return content != null && content.image != null + ? new GUIContent(content.image, tooltip) + : new GUIContent(text, tooltip); + } + + // The Animation window's previous and next frame buttons. + internal static readonly GUIContent StepBack = IconOrText("Animation.PrevKey", "<", "Go back one frame."); + internal static readonly GUIContent StepForward = IconOrText("Animation.NextKey", ">", "Go forward one frame."); + + internal static readonly GUIContent CopyDebugInfo = new GUIContent("Copy State", + "Copy the state, the player's own view and every state change to the clipboard."); + internal static readonly GUIContent ClearHistory = new GUIContent("Clear Events", + "Clear the state changes, so the next ones start from an empty list."); + } + + // Square-ish, sized to the icon rather than stretched across the column. + const float kPlayButtonWidth = 32; + const float kSeekGap = 2; + // Between the play toggle and the step pair, which are drawn joined. + const float kButtonGap = 4; + const float kSeekPadding = 2; + // Either side of the time, inside its box. CalcSize runs a fraction short, and + // the box's border takes a pixel too. + const float kTimePadding = 6; + const float kDebugGap = 5; + // Several seeks' worth, scrolled through rather than all on screen. + const int kHistoryLength = 100; + const float kScrollbarWidth = 16; + // The history is only worth drawing if a few rows of it fit. + const int kMinHistoryRows = 3; + // A backward seek resent this many times before giving up on it. + const int kMaxSeekRetries = 3; + // A player also reads as stopped for a moment between preparing and playing. + const double kStoppedSeconds = 0.5; + + /// + /// What the player is doing, as one value rather than a set of flags that could + /// contradict each other. The player decodes nothing while paused, so showing a + /// frame without playing means playing until it is in and pausing there - which + /// is what the in-between states are. + /// + internal enum State + { + /// No video open. + Closed, + Playing, + /// Playing, with a seek on its way. + Seeking, + /// Paused when the seek was asked for: plays until a frame reaches the target, then pauses on it. + SeekingToPause, + /// Just opened: playing only until the first frame is in, then pausing on it. + PausingOnFrame, + Paused, + /// The player reported an error - see . + Failed + } + + struct Transition + { + internal DateTime Time; + internal State From; + internal State To; + internal string Reason; + } + private VideoPlayer m_Player; private GameObject m_PlayerGO; - internal AndroidLogcatVideoPlayer() + private State m_State = State.Closed; + private readonly Queue m_History = new Queue(); + + // Where a paused video stopped, for Play to start from. Paused from inside the + // player's own callbacks, its clock carries on regardless, and resuming would + // jump ahead by however long it sat paused. -1 when there is nothing to undo. + long m_ResumeFrom = -1; + + // The frame a seek is on its way to. The player's own frame is the old one + // until the seek lands, and stepping from that would undo a quick second + // click. -1 outside a seek. + // Kept while playing too, as a pause before the seek lands waits for it. + long m_SeekTarget = -1; + + // The frame shown when a paused seek began. Frames the player decodes before + // it has actually moved come from around here, and on a seek backwards they are + // past the target without being anywhere near it. -1 outside a seek. + long m_SeekFrom = -1; + int m_SeekRetries; + + // Set once the open video is prepared, which isPrepared may not stay after it stops. + bool m_Prepared; + double m_StoppedSince = -1; + + // The slider being dragged, as its hot control, and whether the video was + // playing when the drag began. Playing on underneath a drag would fight every + // seek it sends, so it pauses until the drag is over. + int m_DragControl; + bool m_ResumeAfterDrag; + + string m_Path; + DateTime m_PathWriteTime; + + // Where Open was asked to pause, sought once the first frame is in. -1 for none. + long m_OpenAt = -1; + + Vector2 m_HistoryScroll; + + internal const string kDefaultName = "LogcatVideoPlayer"; + + /// + /// The hidden GameObject the VideoPlayer lives on. One left behind by an owner + /// that never got to Dispose is picked up again rather than leaked, so each + /// owner needs a name of its own - Dispose destroys whatever it found. + /// + internal AndroidLogcatVideoPlayer(string name = kDefaultName) { - var name = "LogcatVideoPlayer"; m_PlayerGO = GameObject.Find(name); if (m_PlayerGO == null) { @@ -31,13 +163,189 @@ internal AndroidLogcatVideoPlayer() m_Player.renderMode = VideoRenderMode.APIOnly; m_Player.isLooping = true; m_Player.errorReceived += ErrorReceived; + m_Player.seekCompleted += SeekCompleted; + m_Player.frameReady += FrameReady; + m_Player.loopPointReached += LoopPointReached; + m_Player.prepareCompleted += PrepareCompleted; + } + + internal State CurrentState => m_State; + + /// + /// The one place the state changes. Frame events cost a callback per decoded + /// frame, so they are on only while a state is waiting for one. + /// + private void SetState(State state, string reason) + { + if (state == m_State) + return; + + Record(state, reason); + m_State = state; + if (state != State.Paused) + m_ResumeFrom = -1; + if (state != State.Seeking && state != State.SeekingToPause && state != State.PausingOnFrame) + { + m_SeekTarget = -1; + m_SeekFrom = -1; + } + var waiting = state == State.SeekingToPause || state == State.PausingOnFrame; + EditorApplication.update -= CheckStopped; + m_StoppedSince = -1; + if (waiting && m_Player != null) + EditorApplication.update += CheckStopped; + if (m_Player != null) + { + m_Player.sendFrameReadyEvents = waiting; + // Only playback loops. Waiting for a frame past the end would otherwise + // wrap around and play the whole video again to reach it. + m_Player.isLooping = state == State.Playing || state == State.Seeking; + } + } + + /// + /// Something worth seeing in the history that does not change the state - what + /// the player reported while a state waited on it. + /// + private void Note(string reason) + { + Record(m_State, reason); + } + + private void Record(State to, string reason) + { + m_History.Enqueue(new Transition { Time = DateTime.Now, From = m_State, To = to, Reason = reason }); + while (m_History.Count > kHistoryLength) + m_History.Dequeue(); + } + + private void PrepareCompleted(VideoPlayer source) + { + m_Prepared = true; + Note($"prepared, {source.frameCount} frames at {source.frameRate:0.###} fps"); + } + + /// + /// macOS stops a one frame video without a frame or loop point event, which + /// would leave a waiting state waiting forever. + /// + private void CheckStopped() + { + if (m_Player == null || !m_Prepared || m_Player.isPlaying || m_Player.isPaused) + { + m_StoppedSince = -1; + return; + } + var now = EditorApplication.timeSinceStartup; + if (m_StoppedSince < 0) + m_StoppedSince = now; + if (now - m_StoppedSince < kStoppedSeconds) + return; + + m_StoppedSince = -1; + if (m_Player.texture == null) + { + Error = "The player stopped without decoding a frame."; + SetState(State.Failed, "stopped without a frame"); + return; + } + var last = FrameCount - 1; + SetState(State.Paused, $"stopped, on {last}"); + m_ResumeFrom = last; } private void ErrorReceived(VideoPlayer source, string message) { // Stop video manually, otherwise it will spam Editor console. AndroidLogcatInternalLog.Log($"Error received while playing video, stopping video.\n{message}"); + Error = message; source.Stop(); + SetState(State.Failed, "error"); + } + + private void SeekCompleted(VideoPlayer source) + { + switch (m_State) + { + case State.Seeking: + SetState(State.Playing, "seek landed"); + break; + // Done is not always done: the time can still be the old one, and then + // FrameReady decides. When it already reads as the target, the frame is + // in, and no frame event may follow for it. + case State.SeekingToPause: + var shown = Frame; + Note($"seek reported done, time {source.time:0.000}s, shown {shown}"); + if (m_SeekTarget >= 0 && shown == m_SeekTarget) + { + source.Pause(); + SetState(State.Paused, $"seek landed on {shown}"); + m_ResumeFrom = shown; + } + break; + } + } + + /// + /// Only reached while waiting, when nothing loops: what was being waited for is + /// not coming, and the last frame is the nearest there is. + /// + private void LoopPointReached(VideoPlayer source) + { + if (m_State != State.SeekingToPause && m_State != State.PausingOnFrame) + return; + var last = FrameCount - 1; + source.Pause(); + SetState(State.Paused, $"reached the end, on {last}"); + m_ResumeFrom = last; + } + + /// + /// Only sent while waiting for a frame. A paused seek pauses on the first frame + /// at or past its target - neither "seek done" nor "the next frame after it" is + /// that frame. + /// + private void FrameReady(VideoPlayer source, long frameIdx) + { + var shown = frameIdx; + switch (m_State) + { + case State.PausingOnFrame: + source.Pause(); + SetState(State.Paused, $"frame {shown} in"); + m_ResumeFrom = shown; + // A seek needs the video open, which the first frame says it is. + if (m_OpenAt >= 0) + { + var openAt = m_OpenAt; + m_OpenAt = -1; + SeekTo(openAt); + } + break; + case State.SeekingToPause: + Note($"frame ready: {frameIdx}, time {source.time:0.000}s"); + var target = m_SeekTarget; + if (target >= 0 && shown < target) + break; + // Past the target on the way back is still where the seek started from. + if (target >= 0 && shown > target && target < m_SeekFrom && shown >= m_SeekFrom) + { + // Moving on from there means the seek was dropped, as macOS does + // with one sent straight after Play. + if (shown > m_SeekFrom + 1 && m_SeekRetries < kMaxSeekRetries) + { + m_SeekRetries++; + m_SeekFrom = shown; + Note($"seek to {target} dropped, sending it again"); + MoveTo(target); + } + break; + } + source.Pause(); + SetState(State.Paused, shown == target ? $"on {shown}" : $"on {shown}, wanted {target}"); + m_ResumeFrom = shown; + break; + } } public void Dispose() @@ -53,111 +361,474 @@ public void Dispose() GameObject.DestroyImmediate(m_PlayerGO); m_PlayerGO = null; } + SetState(State.Closed, "disposed"); } - public void Play(string path) + /// Why the current video could not be played, empty while it can. + internal string Error { get; private set; } = string.Empty; + + /// + /// Opens a video paused on , or closes the current one when + /// is empty. + /// + public void Open(string path, long frame = 0) { - m_Player.Stop(); + Error = string.Empty; + m_OpenAt = frame > 0 ? frame : -1; + m_Path = path; + m_PathWriteTime = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : default; - if (string.IsNullOrEmpty(path)) - return; - if (!File.Exists(path)) - return; if (m_Player == null) { - AndroidLogcatInternalLog.Log($"Cannot play '{path}', video player was not created ?"); + AndroidLogcatInternalLog.Log($"Cannot open '{path}', video player was not created ?"); + SetState(State.Closed, "no player"); + return; + } + + m_Player.Stop(); + m_DragControl = 0; + m_ResumeAfterDrag = false; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + // Releases the file, which Windows cannot rename or delete while open. + m_Player.url = string.Empty; + SetState(State.Closed, "closed"); return; } + m_Prepared = false; m_Player.url = path; + // There is no seek to wait for, only the first frame. + SetState(State.PausingOnFrame, $"opened {Path.GetFileName(path)}"); + m_SeekTarget = -1; m_Player.Play(); } + /// + /// Whether playback is what the user asked for. A paused video that is playing + /// only to show a frame is still paused as far as anyone looking at it can tell. + /// public bool IsPlaying() { - if (m_Player == null) - return false; - return m_Player.isPlaying; + return m_State == State.Playing || m_State == State.Seeking; } - public Rect GetVideoRect(Rect windowRect) + /// + /// Whether the image can change without anything else happening, so whoever + /// shows it has to keep repainting. A paused video draws nothing new on its own, + /// but one on its way to a frame does. + /// + internal bool NeedsRepaint => m_State == State.Playing || m_State == State.Seeking + || m_State == State.SeekingToPause || m_State == State.PausingOnFrame; + + /// + /// The frame to draw, or null until the player has one. A video is opened + /// asynchronously, so there is nothing to show for the first few frames. + /// + internal Texture Texture => m_Player != null ? m_Player.texture : null; + + /// Zero until the video's size is known. + internal float Aspect => m_Player != null && m_Player.height > 0 + ? (float)m_Player.width / m_Player.height + : 0; + + internal string Dimensions => m_Player != null && m_Player.height > 0 + ? $"{m_Player.width}x{m_Player.height}" + : string.Empty; + + /// Zero until the video has opened. + internal double FrameRate => m_Player != null ? m_Player.frameRate : 0; + + /// Zero until the video has opened. + internal long FrameCount => m_Player != null ? (long)m_Player.frameCount : 0; + + /// Where starts, in seconds. Zero until the video has opened. + internal double TimeOf(long frame) => FrameRate > 0 ? frame / FrameRate : 0; + + /// Zero until the video has opened. + internal double Length => TimeOf(FrameCount); + + /// The frame on screen, counted from zero. + internal long Frame + { + get + { + if (m_Player == null) + return 0; + // macOS rewinds the clock of a video paused at its end. + if (m_State == State.Paused && m_ResumeFrom >= 0) + return m_ResumeFrom; + return Math.Max(0, m_Player.frame); + } + } + + void MoveTo(long frame) + { + m_Player.frame = frame; + } + + /// + /// Seeks, which lands asynchronously. A drag sends seek after seek, and each one + /// moves the frame being waited for. + /// + internal void SeekTo(long frame) { if (m_Player == null) + return; + // Visible in the history rather than lost: a seek that did nothing looks + // exactly like one that landed on the frame it left. + if (!m_Player.canSetTime) { - AndroidLogcatInternalLog.Log($"Cannot get video rect, video player is not created?"); - return Rect.zero; + Note($"seek to {frame} refused: player cannot seek (prepared {m_Player.isPrepared})"); + return; } - if (m_Player.width == 0 || m_Player.height == 0) - return Rect.zero; + frame = Math.Max(0, Math.Min(frame, FrameCount - 1)); - float aspect = (float)m_Player.width / (float)m_Player.height; - var rc = GUILayoutUtility.GetAspectRect(aspect); - Rect r1, r2; + switch (m_State) + { + case State.Playing: + case State.Seeking: + if (m_State == State.Playing) + m_SeekFrom = Frame; + m_SeekRetries = 0; + MoveTo(frame); + SetState(State.Seeking, $"seek to {frame}"); + m_SeekTarget = frame; + break; + case State.Paused: + case State.SeekingToPause: + case State.PausingOnFrame: + // Taken before anything moves, and kept across a drag's later seeks. + if (m_State == State.Paused) + m_SeekFrom = Frame; + m_SeekRetries = 0; + // Set before Play, which can land the seek straight away. + SetState(State.SeekingToPause, $"seek to {frame}"); + m_SeekTarget = frame; + // Play first, seek second, as resuming does. A video paused at its + // end counts as finished, and Play starts it over from the beginning + // - dropping a seek made before it. The frames of that restart are + // before the target, and FrameReady lets them pass. + if (!m_Player.isPlaying) + m_Player.Play(); + MoveTo(frame); + break; + } + } - var correctedHeight = windowRect.height - rc.y - 20; - var s = correctedHeight / rc.height; - r1 = rc; - r1.width *= s; - r1.height *= s; + /// + /// Steps one frame while paused, counting from where a seek still in flight is + /// going rather than from where the player still is. Stops at either end + /// rather than wrapping, so holding back stays on the first frame. + /// + internal void Step(int direction) + { + var to = StepTarget(direction); + if (to >= 0) + SeekTo(to); + } - var correctedWidth = windowRect.width - rc.x; - s = correctedWidth / rc.width; - r2 = rc; - r2.width *= s; - r2.height *= s; + /// Where a step would go, or -1 at that end of the video. + long StepTarget(int direction) + { + var from = m_SeekTarget >= 0 ? m_SeekTarget : Frame; + var to = from + direction; + return to >= 0 && to < FrameCount ? to : -1; + } - var videoRect = r1.width < r2.width ? r1 : r2; + /// + /// Whether this file, as it is on disk now, is what is loaded. When it is, + /// loading it again would only lose where it was and whether it was paused. + /// + internal bool IsShowing(string path) + { + return !string.IsNullOrEmpty(path) && path == m_Path && File.Exists(path) + && File.GetLastWriteTimeUtc(path) == m_PathWriteTime; + } - videoRect.x += (windowRect.width - videoRect.width) * 0.5f; + internal void TogglePlay() + { + if (m_Player == null) + return; - return videoRect; + switch (m_State) + { + case State.Playing: + m_Player.Pause(); + SetState(State.Paused, "pause"); + break; + // The seek still has to land, and pausing before it does would keep + // the old frame - so it pauses on the frame the seek brings. + case State.Seeking: + SetState(State.SeekingToPause, "pause"); + break; + // Already playing underneath: carrying on is all it takes. + case State.SeekingToPause: + SetState(State.Seeking, "play"); + break; + case State.PausingOnFrame: + SetState(State.Playing, "play"); + break; + case State.Paused: + var resumeFrom = m_ResumeFrom; + m_Player.Play(); + if (resumeFrom >= 0) + { + SetState(State.Seeking, $"play from {resumeFrom}"); + // The player's clock ran on while paused, so that is where it seeks from. + m_SeekFrom = Math.Max(0, m_Player.frame); + m_SeekTarget = resumeFrom; + m_SeekRetries = 0; + MoveTo(resumeFrom); + } + else + { + SetState(State.Playing, "play"); + } + break; + } } - private void DoVideoInfoGUI(Rect windowRect, Rect videoRect) + /// Pauses for a drag of the slider, remembering whether to play on after it. + internal void BeginDrag() { - var v = m_Player; - var info = new List(); - info.Add($" Dimensions: {v.width} x {v.height}"); - info.Add($" Length: {v.length:0.00} seconds."); - info.Add($" Frame Count: {v.frameCount}"); - info.Add($" Frame: {v.frame}"); + m_ResumeAfterDrag = IsPlaying(); + if (m_ResumeAfterDrag) + TogglePlay(); + } - var infoRC = new Rect(windowRect.width - 205, videoRect.y, 200, info.Count * 19); - GUI.Box(infoRC, GUIContent.none, GUI.skin.window); - GUI.Label(infoRC, string.Join("\n", info)); + /// Plays on from wherever the drag left the video, if it was playing before. + internal void EndDrag() + { + if (m_ResumeAfterDrag && !IsPlaying()) + TogglePlay(); + m_ResumeAfterDrag = false; } - public void DoGUI(Rect windowRect) + /// + /// The play toggle, and under it the seek slider with the time, drawn + /// from down within . Whatever does not + /// fit is left out, and ends below what was drawn. + /// + internal void DoControlsGUI(Rect rc, ref float y) { - var show = m_Player != null && m_Player.texture != null; - if (show) + var height = EditorGUIUtility.singleLineHeight; + if (y + height > rc.yMax) + return; + + // A toggle, pressed while playing, as the inspector draws it. + var playing = IsPlaying(); + var x = rc.x; + var buttonRect = ButtonRect(rc, ref x, y, height, Styles.Play); + if (GUI.Toggle(buttonRect, playing, playing ? Styles.Pause : Styles.Play, EditorStyles.miniButton) != playing) + TogglePlay(); + + // Stepping is for looking at one frame at a time, which playing moves on from. + var canStep = !playing && m_State != State.Closed && m_State != State.Failed && FrameCount > 1; + x += kButtonGap; + EditorGUI.BeginDisabledGroup(!canStep || StepTarget(-1) < 0); + if (GUI.Button(ButtonRect(rc, ref x, y, height, Styles.StepBack), Styles.StepBack, EditorStyles.miniButtonLeft)) + Step(-1); + EditorGUI.EndDisabledGroup(); + EditorGUI.BeginDisabledGroup(!canStep || StepTarget(1) < 0); + if (GUI.Button(ButtonRect(rc, ref x, y, height, Styles.StepForward), Styles.StepForward, EditorStyles.miniButtonRight)) + Step(1); + EditorGUI.EndDisabledGroup(); + + // The time, in a box at the right end of the row, so the slider gets the full width. + var frameCount = FrameCount; + var frame = Frame; + // Minutes only when the recording runs that long, so both times fit the box. + var minutes = Length >= 60; + var length = frameCount > 0 ? FormatTime(Length, minutes) : "-.--"; + var time = frameCount > 0 ? $"{FormatTime(TimeOf(frame), minutes)} / {length}" : "-.-- / -.--"; + // Sized to the longest the text gets, so the box does not change width as it plays. + var timeWidth = Mathf.Ceil(Styles.FrameCounter.CalcSize(new GUIContent($"{length} / {length}")).x) + 2 * kTimePadding; + var timeX = Mathf.Max(x + kButtonGap, rc.xMax - timeWidth); + var timeRect = new Rect(timeX, y, Mathf.Max(0, rc.xMax - timeX), height); + GUI.Box(timeRect, GUIContent.none, EditorStyles.helpBox); + GUI.Label(new Rect(timeRect.x + kTimePadding / 2, timeRect.y, Mathf.Max(0, timeRect.width - kTimePadding), height), + time, Styles.FrameCounter); + y += height; + + // Its own row, the whole width of the column. + var boxHeight = height + 2 * kSeekPadding; + if (y + kSeekGap + boxHeight > rc.yMax) + return; + y += kSeekGap; + var boxRect = new Rect(rc.x, y, rc.width, boxHeight); + GUI.Box(boxRect, GUIContent.none, EditorStyles.helpBox); + + var sliderRect = new Rect(boxRect.x + kSeekPadding, boxRect.y + kSeekPadding, + Mathf.Max(0, boxRect.width - 2 * kSeekPadding), height); + + // The slider takes the mouse on the press and lets go on the release, which + // can be outside this window - so a drag ends when it is no longer hot, + // whatever event that turns up on. Paused before the slider handles the + // press, so even the press's own seek is a paused one. + if (m_DragControl != 0 && GUIUtility.hotControl != m_DragControl) { - var icon = m_Player.isPlaying ? AndroidLogcatStyles.kIconPlay[0] : AndroidLogcatStyles.kIconPlay[1]; - EditorGUILayout.BeginHorizontal(); - GUILayout.Label("Preview", AndroidLogcatStyles.toolbarLabelLeft); - if (GUILayout.Button(icon, AndroidLogcatStyles.toolbarButton, GUILayout.Width(30), GUILayout.Height(30))) - { - if (m_Player.isPlaying) - m_Player.Pause(); - else - m_Player.Play(); - } - EditorGUILayout.EndHorizontal(); + m_DragControl = 0; + EndDrag(); } + var e = Event.current; + var pressed = GUI.enabled && frameCount > 1 && e.type == EventType.MouseDown && e.button == 0 + && sliderRect.Contains(e.mousePosition); + if (pressed) + BeginDrag(); + + // While dragging, the thumb stays where it was dragged to rather than going + // back to the frame the player has caught up to so far. + var thumb = TimeOf(m_DragControl != 0 && m_SeekTarget >= 0 ? m_SeekTarget : frame); - // GetVideoRect allocates gui layout thus it must after the above block - var rc = GetVideoRect(windowRect); - if (show) + EditorGUI.BeginDisabledGroup(frameCount <= 1); + EditorGUI.BeginChangeCheck(); + var picked = GUI.HorizontalSlider(sliderRect, (float)thumb, 0, (float)TimeOf(Math.Max(0, frameCount - 1))); + if (EditorGUI.EndChangeCheck()) + SeekTo((long)Math.Round(picked * FrameRate)); + EditorGUI.EndDisabledGroup(); + + if (pressed) { - GUI.DrawTexture(rc, m_Player.texture); - DoVideoInfoGUI(windowRect, rc); + if (GUIUtility.hotControl != 0) + m_DragControl = GUIUtility.hotControl; + else + EndDrag(); } - else + y += boxHeight; + + if (Unsupported.IsDeveloperMode()) + DoDebugGUI(rc, ref y); + } + + /// Seconds and hundredths, such as 5.37, or with minutes, such as 1:05.37. + internal static string FormatTime(double seconds, bool minutes = true) + { + var hundredths = (long)Math.Round(Math.Max(0, seconds) * 100); + return minutes + ? $"{hundredths / 6000}:{hundredths / 100 % 60:00}.{hundredths % 100:00}" + : $"{hundredths / 100}.{hundredths % 100:00}"; + } + + /// + /// The next button along the row: icon sized when there is an icon, as wide as + /// its text when the icon could not be found. Clipped to the column. + /// + static Rect ButtonRect(Rect rc, ref float x, float y, float height, GUIContent content) + { + var width = content.image != null + ? kPlayButtonWidth + : EditorStyles.miniButton.CalcSize(content).x; + var rect = new Rect(x, y, Mathf.Max(0, Mathf.Min(width, rc.xMax - x)), height); + x = rect.xMax; + return rect; + } + + /// + /// Developer mode only: the state, what the player itself says, and how the + /// state got here, newest first. Each row's tooltip has the full transition. + /// + void DoDebugGUI(Rect rc, ref float y) + { + y += kDebugGap; + // Headed the way the live view heads its own debugging details. + if (!AndroidLogcatStatsColumn.DeveloperModeGUI(rc, ref y)) + return; + var height = EditorGUIUtility.singleLineHeight; + + foreach (var line in DebugStatus()) + DebugLine(rc, ref y, line, null); + + // Clear Events and Copy State, joined, under the list. + var buttonsY = rc.yMax - height; + if (buttonsY < y) + return; + DoHistoryGUI(new Rect(rc.x, y, rc.width, buttonsY - y)); + y = rc.yMax; + var clearWidth = EditorStyles.miniButtonLeft.CalcSize(Styles.ClearHistory).x; + if (GUI.Button(new Rect(rc.x, buttonsY, clearWidth, height), Styles.ClearHistory, EditorStyles.miniButtonLeft)) { + m_History.Clear(); + m_HistoryScroll = Vector2.zero; + } + var copyWidth = EditorStyles.miniButtonRight.CalcSize(Styles.CopyDebugInfo).x; + if (GUI.Button(new Rect(rc.x + clearWidth, buttonsY, copyWidth, height), Styles.CopyDebugInfo, + EditorStyles.miniButtonRight)) + EditorGUIUtility.systemCopyBuffer = GetDebugInfo(); + } + + /// What the panel shows above the history, one line each. + IEnumerable DebugStatus() + { + yield return $"State: {m_State}"; + if (m_Player == null) + yield break; + var player = m_Player.isPlaying ? "playing" : m_Player.isPaused ? "paused" : "stopped"; + yield return $"Player: {player}, prepared {m_Player.isPrepared}, frame {m_Player.frame}, time {m_Player.time:0.000}s"; + yield return $"Shown: {Frame} of {FrameCount} at {FrameRate:0.###} fps, resume {m_ResumeFrom}, " + + $"seek {m_SeekFrom} -> {m_SeekTarget}"; + } + + /// + /// Everything the panel shows, plus what it has no room for, as plain text to + /// paste into a bug report: the file and the whole history oldest first, with + /// from and to. + /// + internal string GetDebugInfo() + { + var info = new StringBuilder(); + info.AppendLine($"Video: {(string.IsNullOrEmpty(m_Path) ? "-" : Path.GetFileName(m_Path))}"); + foreach (var line in DebugStatus()) + info.AppendLine(line); + + info.AppendLine("State changes, oldest first:"); + foreach (var t in m_History) + info.AppendLine($" {t.Time:HH:mm:ss.fff} {t.From} -> {t.To}: {t.Reason}"); + return info.ToString(); + } - EditorGUILayout.HelpBox("No video to show.", MessageType.Info); + /// + /// The state changes, newest first, in whatever is left of the column. Each + /// row's tooltip has the full transition. Rows are as wide as their text, and + /// scroll sideways when the column is narrower. + /// + void DoHistoryGUI(Rect rc) + { + var rowHeight = EditorGUIUtility.singleLineHeight; + if (rc.height < rowHeight * kMinHistoryRows) + return; + + var transitions = m_History.ToArray(); + var rows = new GUIContent[transitions.Length]; + var contentWidth = 0f; + for (var i = 0; i < transitions.Length; i++) + { + var t = transitions[transitions.Length - 1 - i]; + rows[i] = new GUIContent($"{t.Time:ss.fff} {t.To} ({t.Reason})", + $"{t.Time:HH:mm:ss.fff} {t.From} -> {t.To}: {t.Reason}"); + contentWidth = Mathf.Max(contentWidth, EditorStyles.miniLabel.CalcSize(rows[i]).x); } + + GUI.Box(rc, GUIContent.none, EditorStyles.helpBox); + var view = new Rect(rc.x + kSeekPadding, rc.y + kSeekPadding, + Mathf.Max(0, rc.width - 2 * kSeekPadding), Mathf.Max(0, rc.height - 2 * kSeekPadding)); + var content = new Rect(0, 0, Mathf.Max(contentWidth, view.width - kScrollbarWidth), + rows.Length * rowHeight); + + m_HistoryScroll = GUI.BeginScrollView(view, m_HistoryScroll, content); + for (var i = 0; i < rows.Length; i++) + GUI.Label(new Rect(0, i * rowHeight, content.width, rowHeight), rows[i], EditorStyles.miniLabel); + GUI.EndScrollView(); + } + + static void DebugLine(Rect rc, ref float y, string text, string tooltip) + { + var height = EditorGUIUtility.singleLineHeight; + if (y + height > rc.yMax) + return; + GUI.Label(new Rect(rc.x, y, rc.width, height), new GUIContent(text, tooltip), EditorStyles.miniLabel); + y += height; } } } diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs index 4a54dc8b..0f4d7eb0 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatFakeDevice.cs @@ -47,6 +47,8 @@ internal override void SetRotation(AndroidDeviceRotation rotation) /// Nothing that folds. internal override FoldStates QueryFoldStates() => FoldStates.None; + internal override IReadOnlyList QueryPhysicalDisplaySizes() => Array.Empty(); + internal override void SetDeviceState(int identifier) { } diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs index 77a3c1bf..9b4ae71e 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatGeneralTests.cs @@ -104,7 +104,7 @@ public void AReservationIsListedOnlyInTheFolderItWasMadeIn() var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); var device = new AndroidLogcatFakeDevice90("unittest-device"); - var reserved = captureScreenshot.AllocateImagePath(device); + var reserved = captureScreenshot.AllocateCapturePath(device, captureScreenshot.GetImageExtension()); StringAssert.StartsWith(first, reserved, "The reservation belongs to the folder in use"); Assert.IsTrue(captureScreenshot.GetScreenshots().Any(s => s.Path == reserved), "A capture in flight is listed, so the next one cannot reuse its number"); @@ -170,6 +170,52 @@ public void RenameIsRefusedWhenTheDetailsNameIsTaken() } } + /// A screenshot and a recording of one name would share a details file. + [Test] + public void RenameIsRefusedWhenAnotherKindOfCaptureHasTheName() + { + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + try + { + var directory = CreateCapturesFolder(); + var captureScreenshot = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + + var image = System.IO.Path.Combine(directory, "unittest-rename_1.png").Replace("\\", "/"); + var recording = System.IO.Path.Combine(directory, "unittest-rename-taken.mp4").Replace("\\", "/"); + var details = System.IO.Path.Combine(directory, "unittest-rename-taken.json").Replace("\\", "/"); + var renamed = System.IO.Path.Combine(directory, "unittest-rename-free.png").Replace("\\", "/"); + + System.IO.File.WriteAllBytes(image, new byte[] { 1, 2, 3 }); + System.IO.File.WriteAllBytes(recording, new byte[] { 4, 5, 6 }); + new AndroidLogcatScreenshotInfo { deviceName = "The recording's device" }.Save(recording); + var recordingDetails = System.IO.File.ReadAllText(details); + + try + { + LogAssert.Expect(LogType.Error, new Regex("unittest-rename-taken.mp4' already exists")); + Assert.IsFalse(captureScreenshot.RenameScreenshot(image, "unittest-rename-taken"), + "The rename should be refused"); + FileAssert.Exists(image); + FileAssert.DoesNotExist(System.IO.Path.Combine(directory, "unittest-rename-taken.png")); + Assert.AreEqual(recordingDetails, System.IO.File.ReadAllText(details), + "The recording's details should be left alone"); + + Assert.IsTrue(captureScreenshot.RenameScreenshot(image, "unittest-rename-free"), + "A name nothing else holds should rename"); + FileAssert.Exists(renamed); + } + finally + { + System.IO.Directory.Delete(directory, true); + } + } + finally + { + runtime.Shutdown(); + } + } + [Test] public void SettingsRangeTests() { @@ -248,9 +294,22 @@ public void LiveStreamSettingsTests() Assert.AreEqual(17, settings.MessageFontSize, "A live stream reset should not touch the font size"); Assert.AreEqual(1234, settings.MaxCachedMessageCount, "A live stream reset should not touch the message cap"); + Assert.AreEqual(AndroidLogcatSettings.kRecordingTimeLimit.Default, settings.RecordingTimeLimit); + settings.RecordingTimeLimit = 0; + Assert.AreEqual(AndroidLogcatSettings.kRecordingTimeLimit.Min, settings.RecordingTimeLimit); + settings.RecordingTimeLimit = 1000; + Assert.AreEqual(AndroidLogcatSettings.kRecordingTimeLimit.Max, settings.RecordingTimeLimit); + Assert.IsTrue(settings.RecordingSquareVideo, "Square video should be on by default"); + settings.RecordingSquareVideo = false; + Assert.IsTrue(settings.RecordTouches, "Recording touches should be on by default"); + settings.RecordTouches = false; + // And the whole page Reset takes them with everything else. settings.LiveStreamMaxSize = 512; settings.Reset(); + Assert.AreEqual(AndroidLogcatSettings.kRecordingTimeLimit.Default, settings.RecordingTimeLimit); + Assert.IsTrue(settings.RecordingSquareVideo, "Reset should turn square video back on"); + Assert.IsTrue(settings.RecordTouches, "Reset should turn recording touches back on"); Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxSize.Default, settings.LiveStreamMaxSize); Assert.AreEqual(AndroidLogcatSettings.kLiveStreamQuality.Default, settings.LiveStreamQuality); Assert.AreEqual(AndroidLogcatSettings.kLiveStreamMaxFps.Default, settings.LiveStreamMaxFps); diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatNetTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatNetTests.cs index 45e5d2d0..e64f4111 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatNetTests.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatNetTests.cs @@ -47,7 +47,7 @@ static bool IsFrameworkReference(string name) => "UnityEngine.UIElementsModule", "UnityEngine.ImageConversionModule", "UnityEngine.JSONSerializeModule", - "UnityEditor.CoreModule" + "UnityEditor.CoreModule", }); #if UNITY_6000_1_OR_NEWER // While running tests in Yamato in logcat package, the active build target is Android @@ -61,6 +61,13 @@ static bool IsFrameworkReference(string name) => }); }; #endif +#if UNITY_6000_3_OR_NEWER + expectedReferences.AddRange(new[] + { + "UnityEditor.VideoModule", + "UnityEditor.MediaModule", + }); +#endif #if UNITY_6000_5_OR_NEWER expectedReferences.AddRange(new[] { @@ -73,6 +80,7 @@ static bool IsFrameworkReference(string name) => { "UnityEngine.ScriptingModule", "UnityEngine.UICommonModule", + "UnityEngine.TypeRegistryModule", }); #endif diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs new file mode 100644 index 00000000..04fd91d1 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using NUnit.Framework; +using Unity.Android.Logcat; +using UnityEngine; +using Object = UnityEngine.Object; + +/// +/// The recorder driven directly, the way the live stream drives it but without a +/// device: frames are queued, and nothing decodes them until Stop, as when the +/// Editor falls behind. +/// +class AndroidLogcatStreamRecorderTests +{ + const int kFrameRate = 30; + const long kFrameUs = 1000000 / kFrameRate; + + string m_Folder; + AndroidLogcatStreamRecorder m_Recorder; + + [SetUp] + public void SetUp() + { + // The Linux Editor has no H.264 encoder. + if (Application.platform == RuntimePlatform.LinuxEditor) + Assert.Ignore("There is no H.264 in the Linux Editor."); + + m_Folder = Path.Combine(Path.GetTempPath(), "AndroidLogcatTests", Guid.NewGuid().ToString("N")).Replace("\\", "/"); + Directory.CreateDirectory(m_Folder); + } + + [TearDown] + public void TearDown() + { + // Only a test that failed before stopping it leaves one, and its file has to be + // closed before it can be deleted. + m_Recorder?.Stop(); + m_Recorder = null; + if (m_Folder != null && Directory.Exists(m_Folder)) + Directory.Delete(m_Folder, true); + } + + [Test] + public void ChangesOfAQueuedFrameKeepTheirTimeAfterALaterTouch() + { + // Landscape, then portrait a frame later - a fold or a turn - both still queued. + // Encoded first: the recording's clock starts with the recorder. + var landscape = Jpeg(128, 64); + var portrait = Jpeg(64, 128); + m_Recorder = new AndroidLogcatStreamRecorder(Path.Combine(m_Folder, "touch.mp4"), kFrameRate, + null, 0, Vector2Int.zero, rotation => 0); + m_Recorder.Enqueue(landscape, landscape.Length, 0, 0, 2424, 1080); + m_Recorder.Enqueue(portrait, portrait.Length, kFrameUs, 0, 1080, 2424); + + // Well past both frames' slots, on the recording's clock. + Thread.Sleep(300); + m_Recorder.NoteTouch(AndroidLogcatLiveStream.TouchAction.Down, 0.5f, 0.5f); + + // Decodes the queued frames, whose changes are timed earlier than the touch. + var recorder = m_Recorder; + m_Recorder = null; + Assert.IsTrue(recorder.Stop(), $"Nothing was recorded. {recorder.Error}"); + + var changes = recorder.FrameChanges; + var times = changes.Select(change => change.time).ToArray(); + Assert.That(times, Is.Ordered.Ascending.And.Unique, $"Changes out of order: {string.Join(", ", times)}"); + + var turned = changes.Single(change => change.display.IsSet && change.display.width == 1080); + var touch = changes.Single(change => change.touches.Count > 0); + // A frame after the first, give or take how long queueing took. + Assert.That(turned.time, Is.InRange(1.0 / kFrameRate, 3.0 / kFrameRate), "The portrait frame's change moved off its own slot"); + Assert.Less(turned.time, touch.time, "The portrait frame's change should come before the touch"); + Assert.IsTrue(turned.content.IsSet, "The portrait frame sits differently in the video, so its crop should change too"); + Assert.IsEmpty(turned.touches, "The touch was merged into the frame's change"); + Assert.IsFalse(touch.display.IsSet, "The frame's display change was merged into the touch"); + } + + static byte[] Jpeg(int width, int height) + { + var texture = new Texture2D(width, height, TextureFormat.RGB24, false); + try + { + var pixels = new Color32[width * height]; + for (var i = 0; i < pixels.Length; i++) + pixels[i] = new Color32((byte)(i % 256), 128, 64, 255); + texture.SetPixels32(pixels); + texture.Apply(); + return texture.EncodeToJPG(); + } + finally + { + Object.DestroyImmediate(texture); + } + } +} diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs.meta b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs.meta new file mode 100644 index 00000000..73dfbbd1 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatStreamRecorderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 305fc0de5f5d45c89b836d0c3b576805 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs new file mode 100644 index 00000000..d5f7dc21 --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs @@ -0,0 +1,504 @@ +using System; +using System.Collections; +using System.IO; +using NUnit.Framework; +using Unity.Android.Logcat; +using UnityEditor; +using UnityEditor.Media; +using UnityEngine; +using UnityEngine.TestTools; +using State = Unity.Android.Logcat.AndroidLogcatVideoPlayer.State; + +/// +/// The capture list's video player, against videos made here the way recordings are +/// made: H.264 at a constant 30 fps, so VideoPlayer's frame numbers are exact. +/// +class AndroidLogcatVideoPlayerTests +{ + const int kFrameRate = 30; + const int kFrameCount = 90; + const int kSize = 128; + // Decoding to the far end of a video is the slowest thing here, and well within + // this on any machine. + const double kTimeout = 10; + + static string s_Folder; + static string s_VideoPath; + static string s_SingleFramePath; + static string s_Unavailable; + + AndroidLogcatVideoPlayer m_Player; + + [OneTimeSetUp] + public void WriteVideos() + { + // The Linux Editor has no H.264 encoder or decoder. + if (Application.platform == RuntimePlatform.LinuxEditor) + { + s_Unavailable = "There is no H.264 in the Linux Editor."; + return; + } + + s_Folder = Path.Combine(Path.GetTempPath(), "AndroidLogcatTests", Guid.NewGuid().ToString("N")).Replace("\\", "/"); + Directory.CreateDirectory(s_Folder); + s_VideoPath = Path.Combine(s_Folder, "video.mp4").Replace("\\", "/"); + s_SingleFramePath = Path.Combine(s_Folder, "single.mp4").Replace("\\", "/"); + try + { + WriteVideo(s_VideoPath, kFrameCount); + WriteVideo(s_SingleFramePath, 1); + } + catch (Exception ex) + { + s_Unavailable = $"The test videos could not be written: {ex.Message}"; + } + } + + [OneTimeTearDown] + public void DeleteVideos() + { + if (s_Folder != null && Directory.Exists(s_Folder)) + Directory.Delete(s_Folder, true); + } + + [SetUp] + public void SetUp() + { + if (s_Unavailable != null && IsPlaybackTest()) + Assert.Ignore(s_Unavailable); + } + + [TearDown] + public void TearDown() + { + m_Player?.Dispose(); + m_Player = null; + } + + bool IsPlaybackTest() + { + var method = GetType().GetMethod(TestContext.CurrentContext.Test.MethodName); + return method != null && method.IsDefined(typeof(UnityTestAttribute), false); + } + + /// A frame a shade lighter each time, so consecutive frames differ. + static void WriteVideo(string path, int frames) + { + var h264 = new H264EncoderAttributes + { + gopSize = kFrameRate, + numConsecutiveBFrames = 0, + profile = VideoEncodingProfile.H264Baseline + }; + var attributes = new VideoTrackEncoderAttributes(h264) + { + frameRate = new MediaRational(kFrameRate), + width = kSize, + height = kSize, + includeAlpha = false, + targetBitRate = 1000000 + }; + var texture = new Texture2D(kSize, kSize, TextureFormat.RGBA32, false); + try + { + using (var encoder = new MediaEncoder(path, attributes)) + { + var pixels = new Color32[kSize * kSize]; + for (var i = 0; i < frames; i++) + { + var shade = (byte)(i * 255 / Math.Max(1, frames - 1)); + for (var p = 0; p < pixels.Length; p++) + pixels[p] = new Color32(shade, (byte)(255 - shade), (byte)(p % kSize * 2), 255); + texture.SetPixels32(pixels); + texture.Apply(false); + if (!encoder.AddFrame(texture)) + throw new Exception($"Frame {i} was refused."); + } + } + } + finally + { + UnityEngine.Object.DestroyImmediate(texture); + } + } + + [Test] + public void FormatsTimeAsMinutesSecondsAndHundredths() + { + Assert.AreEqual("0:00.00", AndroidLogcatVideoPlayer.FormatTime(0)); + Assert.AreEqual("0:03.00", AndroidLogcatVideoPlayer.FormatTime(3)); + Assert.AreEqual("0:00.03", AndroidLogcatVideoPlayer.FormatTime(1.0 / 30)); + Assert.AreEqual("1:05.34", AndroidLogcatVideoPlayer.FormatTime(65.338)); + Assert.AreEqual("1:00.00", AndroidLogcatVideoPlayer.FormatTime(59.996)); + Assert.AreEqual("0:00.00", AndroidLogcatVideoPlayer.FormatTime(-1)); + Assert.AreEqual("4.60", AndroidLogcatVideoPlayer.FormatTime(4.6, false)); + Assert.AreEqual("0.03", AndroidLogcatVideoPlayer.FormatTime(1.0 / 30, false)); + } + + [UnityTest] + public IEnumerator OpensPausedOnTheFirstFrame() + { + yield return Open(); + + Assert.IsFalse(m_Player.IsPlaying()); + Assert.AreEqual(0, m_Player.Frame, Describe()); + Assert.AreEqual(kFrameCount, m_Player.FrameCount, Describe()); + Assert.AreEqual(kFrameRate, m_Player.FrameRate, 0.01, Describe()); + Assert.AreEqual(kFrameCount / (double)kFrameRate, m_Player.Length, 0.01); + Assert.AreEqual(1.0, m_Player.TimeOf(kFrameRate), 0.001); + Assert.IsNotNull(m_Player.Texture, "Opened paused, but with nothing to show."); + Assert.IsTrue(m_Player.IsShowing(s_VideoPath)); + } + + [UnityTest] + public IEnumerator PlaysAndPauses() + { + yield return Open(); + + m_Player.TogglePlay(); + Assert.IsTrue(m_Player.IsPlaying()); + yield return WaitFor("playback to move past frame 10", () => m_Player.Frame > 10); + + m_Player.TogglePlay(); + Assert.AreEqual(State.Paused, m_Player.CurrentState, Describe()); + var pausedOn = m_Player.Frame; + + yield return WaitSeconds(0.5); + Assert.AreEqual(pausedOn, m_Player.Frame, $"Paused, but the frame moved on. {Describe()}"); + } + + [UnityTest] + public IEnumerator StepsOneFrameAtATimeWhilePaused() + { + yield return Open(); + + for (var i = 1; i <= 9; i++) + { + m_Player.Step(1); + yield return WaitForPaused($"stepping forward to {i}"); + Assert.AreEqual(i, m_Player.Frame, Describe()); + } + + for (var i = 8; i >= 5; i--) + { + m_Player.Step(-1); + yield return WaitForPaused($"stepping back to {i}"); + Assert.AreEqual(i, m_Player.Frame, Describe()); + } + } + + [UnityTest] + public IEnumerator StepsThroughEveryFrame() + { + yield return Open(); + + for (var i = 1; i < kFrameCount; i++) + { + m_Player.Step(1); + yield return WaitForPaused($"stepping forward to {i}"); + Assert.AreEqual(i, m_Player.Frame, Describe()); + } + } + + [UnityTest] + public IEnumerator StepStopsAtEitherEnd() + { + yield return Open(); + + m_Player.Step(-1); + Assert.AreEqual(State.Paused, m_Player.CurrentState, "Stepping back from the first frame should do nothing."); + Assert.AreEqual(0, m_Player.Frame); + + const int last = kFrameCount - 1; + m_Player.SeekTo(last); + yield return WaitForPaused("the seek to the last frame"); + Assert.AreEqual(last, m_Player.Frame, Describe()); + + m_Player.Step(1); + Assert.AreEqual(State.Paused, m_Player.CurrentState, "Stepping forward from the last frame should do nothing."); + Assert.AreEqual(last, m_Player.Frame); + + // Back from the end, twice: a video paused on its last frame counts as finished, + // and the seek has to survive Play starting it over. + for (var i = last - 1; i >= last - 2; i--) + { + m_Player.Step(-1); + yield return WaitForPaused($"stepping back to {i}"); + Assert.AreEqual(i, m_Player.Frame, Describe()); + } + } + + [UnityTest] + public IEnumerator SeeksForwardAndBackWhilePaused() + { + yield return Open(); + + foreach (var target in new long[] { 50, 80, 10, 7, 60 }) + { + m_Player.SeekTo(target); + yield return WaitForPaused($"the seek to {target}"); + Assert.AreEqual(target, m_Player.Frame, Describe()); + Assert.IsFalse(m_Player.IsPlaying(), "A seek while paused should leave the video paused."); + } + } + + [UnityTest] + public IEnumerator SeeksWhilePlayingAndKeepsPlaying() + { + yield return Open(); + m_Player.TogglePlay(); + + m_Player.SeekTo(60); + Assert.AreEqual(State.Seeking, m_Player.CurrentState, $"The seek did not start. {Describe()}"); + // Playing was already the state before the seek, so the frame is what says it landed. + yield return WaitFor("the seek to reach 60", + () => m_Player.CurrentState == State.Playing && m_Player.Frame >= 60); + Assert.IsTrue(m_Player.IsPlaying()); + } + + [UnityTest] + public IEnumerator PausingBeforeASeekBackLandsPausesOnItsTarget() + { + yield return Open(); + m_Player.TogglePlay(); + yield return WaitFor("playback to move past frame 60", () => m_Player.Frame > 60); + + m_Player.SeekTo(10); + m_Player.TogglePlay(); + Assert.AreEqual(State.SeekingToPause, m_Player.CurrentState, Describe()); + yield return WaitForPaused("the seek to 10"); + Assert.AreEqual(10, m_Player.Frame, Describe()); + } + + [UnityTest] + public IEnumerator PlayResumesFromWhereAPausedSeekLanded() + { + yield return Open(); + + m_Player.SeekTo(50); + yield return WaitForPaused("the seek to 50"); + // Long enough for a player clock that kept running while paused to show. + yield return WaitSeconds(1); + + m_Player.TogglePlay(); + yield return WaitFor("playback to resume", () => m_Player.CurrentState == State.Playing); + // At most a few frames on from 50, rather than the second's worth that the clock + // would have run on by. + Assert.That(m_Player.Frame, Is.InRange(50, 60), Describe()); + } + + [UnityTest] + public IEnumerator ClosingLeavesNothingOpen() + { + yield return Open(); + + m_Player.Open(null); + Assert.AreEqual(State.Closed, m_Player.CurrentState); + Assert.IsFalse(m_Player.IsPlaying()); + Assert.IsFalse(m_Player.IsShowing(s_VideoPath)); + } + + // Only the path: changing the file's modification time while it is open is not + // possible on Windows, where VideoPlayer holds it. + [UnityTest] + public IEnumerator IsShowingOnlyTheOpenedFile() + { + yield return Open(); + + Assert.IsTrue(m_Player.IsShowing(s_VideoPath)); + Assert.IsFalse(m_Player.IsShowing(Path.ChangeExtension(s_VideoPath, ".other.mp4")), + "A different file is not the one showing."); + Assert.IsFalse(m_Player.IsShowing(null)); + Assert.IsFalse(m_Player.IsShowing(string.Empty)); + } + + /// The end may come before the frame does, or with no event at all. + [UnityTest] + public IEnumerator OpensASingleFrameRecordingPaused() + { + yield return OpenSingleFrame(); + + Assert.AreEqual(0, m_Player.Frame, Describe()); + Assert.AreEqual(1, m_Player.FrameCount); + Assert.IsNotNull(m_Player.Texture, "Opened paused, but with nothing to show."); + } + + [UnityTest] + public IEnumerator PlaysAndPausesASingleFrameRecording() + { + yield return OpenSingleFrame(); + + m_Player.TogglePlay(); + Assert.IsTrue(m_Player.IsPlaying()); + yield return WaitSeconds(0.5); + Assert.AreNotEqual(State.Failed, m_Player.CurrentState, Describe()); + + m_Player.TogglePlay(); + yield return WaitForPaused("pausing"); + Assert.AreEqual(0, m_Player.Frame, Describe()); + } + + [UnityTest] + public IEnumerator SeekingASingleFrameRecordingStaysOnIt() + { + yield return OpenSingleFrame(); + + m_Player.SeekTo(0); + yield return WaitForPaused("seeking to 0"); + Assert.AreEqual(0, m_Player.Frame, Describe()); + + m_Player.SeekTo(5); + yield return WaitForPaused("seeking past the end"); + Assert.AreEqual(0, m_Player.Frame, Describe()); + } + + /// + /// A platform that cannot decode a one frame video is fine, as long as it says so + /// rather than opening forever. + /// + IEnumerator OpenSingleFrame() + { + m_Player = new AndroidLogcatVideoPlayer(nameof(AndroidLogcatVideoPlayerTests)); + m_Player.Open(s_SingleFramePath); + var start = EditorApplication.timeSinceStartup; + while (m_Player.CurrentState != State.Paused && m_Player.CurrentState != State.Failed) + { + if (EditorApplication.timeSinceStartup - start > kTimeout) + Assert.Fail($"Timed out after {kTimeout} s opening.\n{Describe()}"); + EditorApplication.QueuePlayerLoopUpdate(); + yield return null; + } + if (m_Player.CurrentState == State.Failed) + Assert.Ignore($"This platform cannot decode the video: {m_Player.Error}"); + } + + /// Windows cannot move a file the player has open. + [UnityTest] + public IEnumerator RenamingThePreviewedRecordingMovesIt() + { + var directory = CreateCapturesFolder(); + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + var captures = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + var list = new AndroidLogcatCaptureList(runtime, captures, () => { }, nameof(AndroidLogcatVideoPlayerTests)); + try + { + var path = CopyRecordingTo(directory); + yield return Preview(list, captures, path); + + Assert.IsTrue(list.RenameCapture(path, "unittest-recording-renamed"), "The rename should have succeeded."); + var renamed = Path.Combine(directory, "unittest-recording-renamed.mp4").Replace("\\", "/"); + FileAssert.Exists(renamed); + FileAssert.DoesNotExist(path); + + list.SyncPreview(); + Assert.AreEqual(renamed, list.PreviewPath, "The preview should follow the rename."); + yield return WaitForPaused("the renamed recording to open"); + Assert.IsTrue(m_Player.IsShowing(renamed)); + } + finally + { + list.Dispose(); + runtime.Shutdown(); + Directory.Delete(directory, true); + } + } + + [UnityTest] + public IEnumerator DeletingThePreviewedRecordingRemovesIt() + { + var directory = CreateCapturesFolder(); + var runtime = new AndroidLogcatTestRuntime(); + runtime.Initialize(); + var captures = new AndroidLogcatCaptureScreenshot(runtime, () => directory, true); + var list = new AndroidLogcatCaptureList(runtime, captures, () => { }, nameof(AndroidLogcatVideoPlayerTests)); + try + { + var path = CopyRecordingTo(directory); + yield return Preview(list, captures, path); + + list.DeleteCaptures(captures.GetScreenshots(), new[] { path }, null); + FileAssert.DoesNotExist(path); + + list.SyncPreview(); + Assert.AreEqual(State.Closed, m_Player.CurrentState, "Nothing is left to preview."); + } + finally + { + list.Dispose(); + runtime.Shutdown(); + Directory.Delete(directory, true); + } + } + + static string CreateCapturesFolder() + { + var directory = Path.Combine(Path.GetTempPath(), "AndroidLogcatTests", Guid.NewGuid().ToString("N")).Replace("\\", "/"); + Directory.CreateDirectory(directory); + return directory; + } + + /// A copy the test can move and delete. + static string CopyRecordingTo(string directory) + { + var path = Path.Combine(directory, "unittest-recording_1.mp4").Replace("\\", "/"); + File.Copy(s_VideoPath, path); + return path; + } + + /// Selects the recording and waits until it is open. + IEnumerator Preview(AndroidLogcatCaptureList list, AndroidLogcatCaptureScreenshot captures, string path) + { + captures.SelectImage(path); + list.SyncPreview(); + Assert.AreEqual(path, list.PreviewPath); + m_Player = list.VideoPlayer; + yield return WaitForPaused("the recording to open"); + Assert.IsTrue(m_Player.IsShowing(path)); + } + + IEnumerator Open() + { + // Not the Screen Capture window's, which TearDown would otherwise destroy. + m_Player = new AndroidLogcatVideoPlayer(nameof(AndroidLogcatVideoPlayerTests)); + m_Player.Open(s_VideoPath); + Assert.AreEqual(State.PausingOnFrame, m_Player.CurrentState); + yield return WaitForPaused("the first frame"); + } + + IEnumerator WaitForPaused(string what) + { + return WaitFor(what, () => m_Player.CurrentState == State.Paused); + } + + /// + /// The player only moves on when the player loop runs, which in edit mode is not + /// every Editor update unless asked for. + /// + IEnumerator WaitFor(string what, Func done) + { + var start = EditorApplication.timeSinceStartup; + while (!done()) + { + if (m_Player.CurrentState == State.Failed) + Assert.Fail($"The player failed while waiting for {what}: {m_Player.Error}\n{Describe()}"); + if (EditorApplication.timeSinceStartup - start > kTimeout) + Assert.Fail($"Timed out after {kTimeout} s waiting for {what}.\n{Describe()}"); + EditorApplication.QueuePlayerLoopUpdate(); + yield return null; + } + } + + IEnumerator WaitSeconds(double seconds) + { + var start = EditorApplication.timeSinceStartup; + while (EditorApplication.timeSinceStartup - start < seconds) + { + EditorApplication.QueuePlayerLoopUpdate(); + yield return null; + } + } + + /// The same text as the debug panel's Copy button, for a failure message. + string Describe() => m_Player != null ? m_Player.GetDebugInfo() : "No player."; +} diff --git a/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs.meta b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs.meta new file mode 100644 index 00000000..bcdac1cb --- /dev/null +++ b/com.unity.mobile.android-logcat/Tests/Editor/AndroidLogcatVideoPlayerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2c587eb40ce46c98ae3fe993e07d216 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs index c365164c..d8537b5a 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationScreenCapture.cs @@ -12,7 +12,6 @@ [RequiresAndroidDevice] internal class AndroidLogcatRuntimeIntegrationScreenCapture : AndroidLogcatIntegrationTestBase { - private string VideoPathOnHost => Runtime.CaptureVideo.GetVideoPath(Device); // These tests take captures and clear the folder between them, so they get one of // their own rather than whatever the machine running them has configured. It sits @@ -65,14 +64,14 @@ protected void Deinit() private void Cleanup() { - // Need to kill screen recorder before attempting to delete files - AndroidLogcatUtilities.KillScreenRecordProcess(Runtime, Device); - SafeDeleteOnDevice(Device, AndroidLogcatCaptureVideo.VideoPathOnDevice); - SafeDeleteOnHost(VideoPathOnHost); + Runtime.CaptureVideo.StopRecording(); + Runtime.LiveStream.StopStreaming(); // Start from an empty folder - the fixture's own, so this takes nothing with // it that these tests did not write. Leftovers from an earlier run are still // listed, and they carry another device's prefix or a name a test reuses. + // Recordings live in the same folder and are listed with them. + Runtime.CaptureScreenshot.InvalidateScreenshots(); foreach (var screenshot in Runtime.CaptureScreenshot.GetScreenshots().ToArray()) Runtime.CaptureScreenshot.DeleteScreenshot(screenshot.Path); } @@ -254,82 +253,59 @@ private int CountScreenshotsOf(string devicePrefix) } [UnityTest] - public IEnumerator CanGetVideo() + public IEnumerator CanRecordTheLiveStream() { - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, false); + Runtime.LiveStream.StartStreaming(Device, null); + yield return WaitForCondition("Waiting for the first frame", () => Runtime.LiveStream.FramesReceived > 0); + // Where the recording landed is known when it stops: it is numbered like a + // screenshot, and the number is taken when the recording starts. var recordingResult = AndroidLogcatCaptureVideo.Result.Failure; - Runtime.CaptureVideo.StartRecording(Device, (r, s) => + string recordedPath = null; + Runtime.CaptureVideo.StartRecording(Device, (r, p) => { recordingResult = r; + recordedPath = p; }); // Starting recording without stoping previous one, should throw Assert.Throws(typeof(InvalidOperationException), () => Runtime.CaptureVideo.StartRecording(Device, null)); - yield return WaitForCondition("Waiting for Android's screenrecord to become active", - () => Runtime.CaptureVideo.IsRemoteRecorderActive(Device)); + // Frames only arrive when the screen changes. + SendKeyEvent("KEYCODE_APP_SWITCH"); + SendKeyEvent("KEYCODE_HOME"); + yield return WaitFor(3.0, "Recording video"); - yield return WaitFor(5.0, "Recording video"); var result = Runtime.CaptureVideo.StopRecording(); - Assert.IsTrue(result, "Failed to stop the recording"); + Assert.IsTrue(result, $"Failed to stop the recording: {Runtime.CaptureVideo.Errors}"); Assert.AreEqual(AndroidLogcatCaptureVideo.Result.Success, recordingResult); result = Runtime.CaptureVideo.StopRecording(); Assert.IsFalse(result, "StopRecording should return false, since it was already stopped"); - yield return WaitForCondition("Waiting for Android's screenrecord to quit", - () => !Runtime.CaptureVideo.IsRemoteRecorderActive(Device)); - - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, true); + AssertFileExistanceOnHost(recordedPath, true); + StringAssert.EndsWith(".mp4", recordedPath, "A recording is saved as an .mp4 capture"); - CopyToArtifacts("video.mp4", Runtime.CaptureVideo.GetVideoPath(Device)); + CopyToArtifacts("video.mp4", recordedPath); } - [UnityTest] - public IEnumerator CanGetVideoWithTimeLimit() + [Test] + public void RecordingNeedsTheLiveStream() { - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, false); - - var recordingTime = 5; - var recordingResult = AndroidLogcatCaptureVideo.Result.Failure; - Runtime.CaptureVideo.StartRecording(Device, (r, s) => - { - recordingResult = r; - }, TimeSpan.FromSeconds(recordingTime)); - - yield return WaitForCondition($"Waiting for the recording to stop automatically (Should stop in {recordingTime} seconds)", - () => recordingResult == AndroidLogcatCaptureVideo.Result.Success, 20); - - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, true); - - CopyToArtifacts("video.mp4", Runtime.CaptureVideo.GetVideoPath(Device)); + Assert.IsFalse(Runtime.LiveStream.IsStreaming); + Assert.Throws(typeof(InvalidOperationException), () => Runtime.CaptureVideo.StartRecording(Device, null)); + Assert.IsFalse(Runtime.CaptureVideo.IsRecording); } - [UnityTest] - public IEnumerator CaptureVideoHandlesErrors() + private void SendKeyEvent(string keyCode) { - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, false); - - var recordingResult = AndroidLogcatCaptureVideo.Result.Success; - Runtime.CaptureVideo.StartRecording(Device, (r, p) => + Runtime.Tools.ADB.Run(new[] { - recordingResult = r; - }, TimeSpan.FromSeconds(180), 0, 0); - - yield return WaitForCondition($"Waiting for the recording to fail", - () => recordingResult == AndroidLogcatCaptureVideo.Result.Failure, 20); - var errors = Runtime.CaptureVideo.Errors; - Assert.Greater(errors.Length, 0); - AssertFileExistanceOnDevice(AndroidLogcatCaptureVideo.VideoPathOnDevice, false); - AssertFileExistanceOnHost(VideoPathOnHost, false); - - Debug.Log(errors); - ReportArtifact("errors.txt", errors); + $"-s {Device.Id}", + "shell", + "input", + "keyevent", + keyCode + }, $"Failed to send {keyCode} to the device"); } } diff --git a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationTestBase.cs b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationTestBase.cs index c767e88b..ad4dabf2 100644 --- a/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationTestBase.cs +++ b/com.unity.mobile.android-logcat/Tests/Editor/Integration/AndroidLogcatIntegrationTestBase.cs @@ -66,8 +66,8 @@ protected void InitRuntime() /// /// A device that has dozed off composes nothing, so a mirrored display hands over - /// no frames and `screenrecord` never starts - both of which surface as a test - /// timing out for reasons that have nothing to do with the code under test. Waking + /// no frames, which surfaces as a test timing out for reasons that have nothing to + /// do with the code under test. Waking /// it is part of putting the device in a known state, and it is cheap enough to do /// per test rather than once per fixture. ///