ソースを参照

Fall back to using label for preferred devices (#4171)

* Skips setting undefined device id to sink in audio preview.

* Fallbacks to use labels for user selected devices.

* Fixes comment.
master
Дамян Минков 6年前
コミット
c040b3a7dd
コミッターのメールアドレスに関連付けられたアカウントが存在しません

+ 24
- 10
conference.js ファイルの表示

@@ -718,13 +718,21 @@ export default {
718 718
         this.roomName = options.roomName;
719 719
 
720 720
         return (
721
-            this.createInitialLocalTracksAndConnect(
721
+
722
+            // Initialize the device list first. This way, when creating tracks
723
+            // based on preferred devices, loose label matching can be done in
724
+            // cases where the exact ID match is no longer available, such as
725
+            // when the camera device has switched USB ports.
726
+            this._initDeviceList()
727
+                .catch(error => logger.warn(
728
+                    'initial device list initialization failed', error))
729
+                .then(() => this.createInitialLocalTracksAndConnect(
722 730
                 options.roomName, {
723 731
                     startAudioOnly: config.startAudioOnly,
724 732
                     startScreenSharing: config.startScreenSharing,
725 733
                     startWithAudioMuted: config.startWithAudioMuted,
726 734
                     startWithVideoMuted: config.startWithVideoMuted
727
-                })
735
+                }))
728 736
             .then(([ tracks, con ]) => {
729 737
                 tracks.forEach(track => {
730 738
                     if ((track.isAudioTrack() && this.isLocalAudioMuted())
@@ -769,7 +777,10 @@ export default {
769 777
                     this.setVideoMuteStatus(true);
770 778
                 }
771 779
 
772
-                this._initDeviceList();
780
+                // Initialize device list a second time to ensure device labels
781
+                // get populated in case of an initial gUM acceptance; otherwise
782
+                // they may remain as empty strings.
783
+                this._initDeviceList(true);
773 784
 
774 785
                 if (config.iAmRecorder) {
775 786
                     this.recorder = new Recorder();
@@ -2277,20 +2288,23 @@ export default {
2277 2288
     },
2278 2289
 
2279 2290
     /**
2280
-     * Inits list of current devices and event listener for device change.
2291
+     * Updates the list of current devices.
2292
+     * @param {boolean} setDeviceListChangeHandler - Whether to add the deviceList change handlers.
2281 2293
      * @private
2282 2294
      * @returns {Promise}
2283 2295
      */
2284
-    _initDeviceList() {
2296
+    _initDeviceList(setDeviceListChangeHandler = false) {
2285 2297
         const { mediaDevices } = JitsiMeetJS;
2286 2298
 
2287 2299
         if (mediaDevices.isDeviceListAvailable()
2288 2300
                 && mediaDevices.isDeviceChangeAvailable()) {
2289
-            this.deviceChangeListener = devices =>
2290
-                window.setTimeout(() => this._onDeviceListChanged(devices), 0);
2291
-            mediaDevices.addEventListener(
2292
-                JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED,
2293
-                this.deviceChangeListener);
2301
+            if (setDeviceListChangeHandler) {
2302
+                this.deviceChangeListener = devices =>
2303
+                    window.setTimeout(() => this._onDeviceListChanged(devices), 0);
2304
+                mediaDevices.addEventListener(
2305
+                    JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED,
2306
+                    this.deviceChangeListener);
2307
+            }
2294 2308
 
2295 2309
             const { dispatch } = APP.store;
2296 2310
 

+ 8
- 6
modules/devices/mediaDeviceHelper.js ファイルの表示

@@ -1,6 +1,11 @@
1 1
 /* global APP, JitsiMeetJS */
2 2
 
3 3
 import { getAudioOutputDeviceId } from '../../react/features/base/devices';
4
+import {
5
+    getUserSelectedCameraDeviceId,
6
+    getUserSelectedMicDeviceId,
7
+    getUserSelectedOutputDeviceId
8
+} from '../../react/features/base/settings';
4 9
 
5 10
 /**
6 11
  * Determines if currently selected audio output device should be changed after
@@ -26,8 +31,7 @@ function getNewAudioOutputDevice(newDevices) {
26 31
         return 'default';
27 32
     }
28 33
 
29
-    const settings = APP.store.getState()['features/base/settings'];
30
-    const preferredAudioOutputDeviceId = settings.userSelectedAudioOutputDeviceId;
34
+    const preferredAudioOutputDeviceId = getUserSelectedOutputDeviceId(APP.store.getState());
31 35
 
32 36
     // if the preferred one is not the selected and is available in the new devices
33 37
     // we want to use it as it was just added
@@ -49,8 +53,7 @@ function getNewAudioOutputDevice(newDevices) {
49 53
 function getNewAudioInputDevice(newDevices, localAudio) {
50 54
     const availableAudioInputDevices = newDevices.filter(
51 55
         d => d.kind === 'audioinput');
52
-    const settings = APP.store.getState()['features/base/settings'];
53
-    const selectedAudioInputDeviceId = settings.userSelectedMicDeviceId;
56
+    const selectedAudioInputDeviceId = getUserSelectedMicDeviceId(APP.store.getState());
54 57
     const selectedAudioInputDevice = availableAudioInputDevices.find(
55 58
         d => d.deviceId === selectedAudioInputDeviceId);
56 59
 
@@ -88,8 +91,7 @@ function getNewAudioInputDevice(newDevices, localAudio) {
88 91
 function getNewVideoInputDevice(newDevices, localVideo) {
89 92
     const availableVideoInputDevices = newDevices.filter(
90 93
         d => d.kind === 'videoinput');
91
-    const settings = APP.store.getState()['features/base/settings'];
92
-    const selectedVideoInputDeviceId = settings.userSelectedCameraDeviceId;
94
+    const selectedVideoInputDeviceId = getUserSelectedCameraDeviceId(APP.store.getState());
93 95
     const selectedVideoInputDevice = availableVideoInputDevices.find(
94 96
         d => d.deviceId === selectedVideoInputDeviceId);
95 97
 

+ 5
- 3
react/features/base/devices/actions.js ファイルの表示

@@ -1,5 +1,8 @@
1 1
 import JitsiMeetJS from '../lib-jitsi-meet';
2
-import { updateSettings } from '../settings';
2
+import {
3
+    getUserSelectedOutputDeviceId,
4
+    updateSettings
5
+} from '../settings';
3 6
 
4 7
 import {
5 8
     ADD_PENDING_DEVICE_REQUEST,
@@ -91,8 +94,7 @@ export function configureInitialDevices() {
91 94
 
92 95
         return updateSettingsPromise
93 96
             .then(() => {
94
-                const { userSelectedAudioOutputDeviceId }
95
-                    = getState()['features/base/settings'];
97
+                const userSelectedAudioOutputDeviceId = getUserSelectedOutputDeviceId(getState());
96 98
 
97 99
                 return setAudioOutputDeviceId(userSelectedAudioOutputDeviceId, dispatch)
98 100
                     .catch(ex => logger.warn(`Failed to set audio output device.

+ 35
- 2
react/features/base/devices/functions.js ファイルの表示

@@ -67,6 +67,34 @@ export function getDeviceIdByLabel(state: Object, label: string, kind: string) {
67 67
     }
68 68
 }
69 69
 
70
+/**
71
+ * Finds a device with a label that matches the passed id and returns its label.
72
+ *
73
+ * @param {Object} state - The redux state.
74
+ * @param {string} id - The device id.
75
+ * @param {string} kind - The type of the device. One of "audioInput",
76
+ * "audioOutput", and "videoInput". Also supported is all lowercase versions
77
+ * of the preceding types.
78
+ * @returns {string|undefined}
79
+ */
80
+export function getDeviceLabelById(state: Object, id: string, kind: string) {
81
+    const webrtcKindToJitsiKindTranslator = {
82
+        audioinput: 'audioInput',
83
+        audiooutput: 'audioOutput',
84
+        videoinput: 'videoInput'
85
+    };
86
+
87
+    const kindToSearch = webrtcKindToJitsiKindTranslator[kind] || kind;
88
+
89
+    const device
90
+        = (state['features/base/devices'].availableDevices[kindToSearch] || [])
91
+        .find(d => d.deviceId === id);
92
+
93
+    if (device) {
94
+        return device.label;
95
+    }
96
+}
97
+
70 98
 /**
71 99
  * Returns the devices set in the URL.
72 100
  *
@@ -118,24 +146,29 @@ export function groupDevicesByKind(devices: Object[]): Object {
118 146
  * @param {string} newId - New audio output device id.
119 147
  * @param {Function} dispatch - The Redux dispatch function.
120 148
  * @param {boolean} userSelection - Whether this is a user selection update.
149
+ * @param {?string} newLabel - New audio output device label to store.
121 150
  * @returns {Promise}
122 151
  */
123 152
 export function setAudioOutputDeviceId(
124 153
         newId: string = 'default',
125 154
         dispatch: Function,
126
-        userSelection: boolean = false): Promise<*> {
155
+        userSelection: boolean = false,
156
+        newLabel: ?string): Promise<*> {
127 157
     return JitsiMeetJS.mediaDevices.setAudioOutputDevice(newId)
128 158
         .then(() => {
129 159
             const newSettings = {
130 160
                 audioOutputDeviceId: newId,
131
-                userSelectedAudioOutputDeviceId: undefined
161
+                userSelectedAudioOutputDeviceId: undefined,
162
+                userSelectedAudioOutputDeviceLabel: undefined
132 163
             };
133 164
 
134 165
             if (userSelection) {
135 166
                 newSettings.userSelectedAudioOutputDeviceId = newId;
167
+                newSettings.userSelectedAudioOutputDeviceLabel = newLabel;
136 168
             } else {
137 169
                 // a flow workaround, I needed to add 'userSelectedAudioOutputDeviceId: undefined'
138 170
                 delete newSettings.userSelectedAudioOutputDeviceId;
171
+                delete newSettings.userSelectedAudioOutputDeviceLabel;
139 172
             }
140 173
 
141 174
             return dispatch(updateSettings(newSettings));

+ 6
- 3
react/features/base/devices/middleware.js ファイルの表示

@@ -147,7 +147,8 @@ function _useDevice({ dispatch }, device) {
147 147
     switch (device.kind) {
148 148
     case 'videoinput': {
149 149
         dispatch(updateSettings({
150
-            userSelectedCameraDeviceId: device.deviceId
150
+            userSelectedCameraDeviceId: device.deviceId,
151
+            userSelectedCameraDeviceLabel: device.label
151 152
         }));
152 153
 
153 154
         dispatch(setVideoInputDevice(device.deviceId));
@@ -155,7 +156,8 @@ function _useDevice({ dispatch }, device) {
155 156
     }
156 157
     case 'audioinput': {
157 158
         dispatch(updateSettings({
158
-            userSelectedMicDeviceId: device.deviceId
159
+            userSelectedMicDeviceId: device.deviceId,
160
+            userSelectedMicDeviceLabel: device.label
159 161
         }));
160 162
 
161 163
         dispatch(setAudioInputDevice(device.deviceId));
@@ -165,7 +167,8 @@ function _useDevice({ dispatch }, device) {
165 167
         setAudioOutputDeviceId(
166 168
             device.deviceId,
167 169
             dispatch,
168
-            true)
170
+            true,
171
+            device.label)
169 172
             .then(() => logger.log('changed audio output device'))
170 173
             .catch(err => {
171 174
                 logger.warn(

+ 5
- 2
react/features/base/media/components/AbstractAudio.js ファイルの表示

@@ -2,6 +2,8 @@
2 2
 
3 3
 import { Component } from 'react';
4 4
 
5
+const logger = require('jitsi-meet-logger').getLogger(__filename);
6
+
5 7
 /**
6 8
  * Describes audio element interface used in the base/media feature for audio
7 9
  * playback.
@@ -10,7 +12,7 @@ export type AudioElement = {
10 12
     currentTime: number,
11 13
     pause: () => void,
12 14
     play: () => void,
13
-    setSinkId?: string => void,
15
+    setSinkId?: string => Function,
14 16
     stop: () => void
15 17
 };
16 18
 
@@ -113,7 +115,8 @@ export default class AbstractAudio extends Component<Props> {
113 115
     setSinkId(sinkId: string): void {
114 116
         this._audioElementImpl
115 117
             && typeof this._audioElementImpl.setSinkId === 'function'
116
-            && this._audioElementImpl.setSinkId(sinkId);
118
+            && this._audioElementImpl.setSinkId(sinkId)
119
+                .catch(error => logger.error('Error setting sink', error));
117 120
     }
118 121
 
119 122
     /**

+ 149
- 0
react/features/base/settings/functions.js ファイルの表示

@@ -97,3 +97,152 @@ export function getServerURL(stateful: Object | Function) {
97 97
 
98 98
     return state['features/base/settings'].serverURL || DEFAULT_SERVER_URL;
99 99
 }
100
+
101
+/**
102
+ * Searches known devices for a matching deviceId and fall back to matching on
103
+ * label. Returns the stored preferred cameraDeviceId if a match is not found.
104
+ *
105
+ * @param {Object|Function} stateful - The redux state object or
106
+ * {@code getState} function.
107
+ * @returns {string}
108
+ */
109
+export function getUserSelectedCameraDeviceId(stateful: Object | Function) {
110
+    const state = toState(stateful);
111
+    const {
112
+        userSelectedCameraDeviceId,
113
+        userSelectedCameraDeviceLabel
114
+    } = state['features/base/settings'];
115
+    const { videoInput } = state['features/base/devices'].availableDevices;
116
+
117
+    return _getUserSelectedDeviceId({
118
+        availableDevices: videoInput,
119
+
120
+        // Operating systems may append " #{number}" somewhere in the label so
121
+        // find and strip that bit.
122
+        matchRegex: /\s#\d*(?!.*\s#\d*)/,
123
+        userSelectedDeviceId: userSelectedCameraDeviceId,
124
+        userSelectedDeviceLabel: userSelectedCameraDeviceLabel,
125
+        replacement: ''
126
+    });
127
+}
128
+
129
+/**
130
+ * Searches known devices for a matching deviceId and fall back to matching on
131
+ * label. Returns the stored preferred micDeviceId if a match is not found.
132
+ *
133
+ * @param {Object|Function} stateful - The redux state object or
134
+ * {@code getState} function.
135
+ * @returns {string}
136
+ */
137
+export function getUserSelectedMicDeviceId(stateful: Object | Function) {
138
+    const state = toState(stateful);
139
+    const {
140
+        userSelectedMicDeviceId,
141
+        userSelectedMicDeviceLabel
142
+    } = state['features/base/settings'];
143
+    const { audioInput } = state['features/base/devices'].availableDevices;
144
+
145
+    return _getUserSelectedDeviceId({
146
+        availableDevices: audioInput,
147
+
148
+        // Operating systems may append " ({number}-" somewhere in the label so
149
+        // find and strip that bit.
150
+        matchRegex: /\s\(\d*-\s(?!.*\s\(\d*-\s)/,
151
+        userSelectedDeviceId: userSelectedMicDeviceId,
152
+        userSelectedDeviceLabel: userSelectedMicDeviceLabel,
153
+        replacement: ' ('
154
+    });
155
+}
156
+
157
+/**
158
+ * Searches known devices for a matching deviceId and fall back to matching on
159
+ * label. Returns the stored preferred audioOutputDeviceId if a match is not found.
160
+ *
161
+ * @param {Object|Function} stateful - The redux state object or
162
+ * {@code getState} function.
163
+ * @returns {string}
164
+ */
165
+export function getUserSelectedOutputDeviceId(stateful: Object | Function) {
166
+    const state = toState(stateful);
167
+    const {
168
+        userSelectedAudioOutputDeviceId,
169
+        userSelectedAudioOutputDeviceLabel
170
+    } = state['features/base/settings'];
171
+    const { audioOutput } = state['features/base/devices'].availableDevices;
172
+
173
+    return _getUserSelectedDeviceId({
174
+        availableDevices: audioOutput,
175
+        matchRegex: undefined,
176
+        userSelectedDeviceId: userSelectedAudioOutputDeviceId,
177
+        userSelectedDeviceLabel: userSelectedAudioOutputDeviceLabel,
178
+        replacement: undefined
179
+    });
180
+}
181
+
182
+/**
183
+ * A helper function to abstract the logic for choosing which device ID to
184
+ * use. Falls back to fuzzy matching on label if a device ID match is not found.
185
+ *
186
+ * @param {Object} options - The arguments used to match find the preferred
187
+ * device ID from available devices.
188
+ * @param {Array<string>} options.availableDevices - The array of currently
189
+ * available devices to match against.
190
+ * @param {Object} options.matchRegex - The regex to use to find strings
191
+ * appended to the label by the operating system. The matches will be replaced
192
+ * with options.replacement, with the intent of matching the same device that
193
+ * might have a modified label.
194
+ * @param {string} options.userSelectedDeviceId - The device ID the participant
195
+ * prefers to use.
196
+ * @param {string} options.userSelectedDeviceLabel - The label associated with the
197
+ * device ID the participant prefers to use.
198
+ * @param {string} options.replacement - The string to use with
199
+ * options.matchRegex to remove identifies added to the label by the operating
200
+ * system.
201
+ * @private
202
+ * @returns {string} The preferred device ID to use for media.
203
+ */
204
+function _getUserSelectedDeviceId(options) {
205
+    const {
206
+        availableDevices,
207
+        matchRegex,
208
+        userSelectedDeviceId,
209
+        userSelectedDeviceLabel,
210
+        replacement
211
+    } = options;
212
+
213
+    // If there is no label at all, there is no need to fall back to checking
214
+    // the label for a fuzzy match.
215
+    if (!userSelectedDeviceLabel || !userSelectedDeviceId) {
216
+        return userSelectedDeviceId;
217
+    }
218
+
219
+    const foundMatchingBasedonDeviceId = availableDevices.find(
220
+        candidate => candidate.deviceId === userSelectedDeviceId);
221
+
222
+    // Prioritize matching the deviceId
223
+    if (foundMatchingBasedonDeviceId) {
224
+        return userSelectedDeviceId;
225
+    }
226
+
227
+    const strippedDeviceLabel
228
+        = matchRegex ? userSelectedDeviceLabel.replace(matchRegex, replacement)
229
+            : userSelectedDeviceLabel;
230
+    const foundMatchBasedOnLabel = availableDevices.find(candidate => {
231
+        const { label } = candidate;
232
+
233
+        if (!label) {
234
+            return false;
235
+        } else if (strippedDeviceLabel === label) {
236
+            return true;
237
+        }
238
+
239
+        const strippedCandidateLabel
240
+            = label.replace(matchRegex, replacement);
241
+
242
+        return strippedDeviceLabel === strippedCandidateLabel;
243
+    });
244
+
245
+    return foundMatchBasedOnLabel
246
+        ? foundMatchBasedOnLabel.deviceId : userSelectedDeviceId;
247
+}
248
+

+ 4
- 1
react/features/base/settings/reducer.js ファイルの表示

@@ -33,7 +33,10 @@ const DEFAULT_STATE = {
33 33
     startWithVideoMuted: false,
34 34
     userSelectedAudioOutputDeviceId: undefined,
35 35
     userSelectedCameraDeviceId: undefined,
36
-    userSelectedMicDeviceId: undefined
36
+    userSelectedMicDeviceId: undefined,
37
+    userSelectedAudioOutputDeviceLabel: undefined,
38
+    userSelectedCameraDeviceLabel: undefined,
39
+    userSelectedMicDeviceLabel: undefined
37 40
 };
38 41
 
39 42
 const STORE_NAME = 'features/base/settings';

+ 7
- 3
react/features/base/tracks/functions.js ファイルの表示

@@ -3,6 +3,10 @@
3 3
 import JitsiMeetJS, { JitsiTrackErrors, JitsiTrackEvents }
4 4
     from '../lib-jitsi-meet';
5 5
 import { MEDIA_TYPE } from '../media';
6
+import {
7
+    getUserSelectedCameraDeviceId,
8
+    getUserSelectedMicDeviceId
9
+} from '../settings';
6 10
 
7 11
 const logger = require('jitsi-meet-logger').getLogger(__filename);
8 12
 
@@ -37,13 +41,13 @@ export function createLocalTracksF(
37 41
         // reliance on the global variable APP will go away.
38 42
         store || (store = APP.store); // eslint-disable-line no-param-reassign
39 43
 
40
-        const settings = store.getState()['features/base/settings'];
44
+        const state = store.getState();
41 45
 
42 46
         if (typeof cameraDeviceId === 'undefined' || cameraDeviceId === null) {
43
-            cameraDeviceId = settings.userSelectedCameraDeviceId;
47
+            cameraDeviceId = getUserSelectedCameraDeviceId(state);
44 48
         }
45 49
         if (typeof micDeviceId === 'undefined' || micDeviceId === null) {
46
-            micDeviceId = settings.userSelectedMicDeviceId;
50
+            micDeviceId = getUserSelectedMicDeviceId(state);
47 51
         }
48 52
     }
49 53
 

+ 9
- 3
react/features/device-selection/actions.js ファイルの表示

@@ -6,6 +6,7 @@ import {
6 6
 
7 7
 import { createDeviceChangedEvent, sendAnalytics } from '../analytics';
8 8
 import {
9
+    getDeviceLabelById,
9 10
     setAudioInputDevice,
10 11
     setAudioOutputDeviceId,
11 12
     setVideoInputDevice
@@ -112,7 +113,9 @@ export function submitDeviceSelectionTab(newState) {
112 113
             && newState.selectedVideoInputId
113 114
                 !== currentState.selectedVideoInputId) {
114 115
             dispatch(updateSettings({
115
-                userSelectedCameraDeviceId: newState.selectedVideoInputId
116
+                userSelectedCameraDeviceId: newState.selectedVideoInputId,
117
+                userSelectedCameraDeviceLabel:
118
+                    getDeviceLabelById(getState(), newState.selectedVideoInputId, 'videoInput')
116 119
             }));
117 120
 
118 121
             dispatch(
@@ -123,7 +126,9 @@ export function submitDeviceSelectionTab(newState) {
123 126
                 && newState.selectedAudioInputId
124 127
                   !== currentState.selectedAudioInputId) {
125 128
             dispatch(updateSettings({
126
-                userSelectedMicDeviceId: newState.selectedAudioInputId
129
+                userSelectedMicDeviceId: newState.selectedAudioInputId,
130
+                userSelectedMicDeviceLabel:
131
+                    getDeviceLabelById(getState(), newState.selectedAudioInputId, 'audioInput')
127 132
             }));
128 133
 
129 134
             dispatch(
@@ -138,7 +143,8 @@ export function submitDeviceSelectionTab(newState) {
138 143
             setAudioOutputDeviceId(
139 144
                 newState.selectedAudioOutputId,
140 145
                 dispatch,
141
-                true)
146
+                true,
147
+                getDeviceLabelById(getState(), newState.selectedAudioOutputId, 'audioOutput'))
142 148
                 .then(() => logger.log('changed audio output device'))
143 149
                 .catch(err => {
144 150
                     logger.warn(

+ 1
- 0
react/features/device-selection/components/AudioOutputPreview.js ファイルの表示

@@ -113,6 +113,7 @@ class AudioOutputPreview extends Component<Props> {
113 113
      */
114 114
     _setAudioSink() {
115 115
         this._audioElement
116
+            && this.props.deviceId
116 117
             && this._audioElement.setSinkId(this.props.deviceId);
117 118
     }
118 119
 }

+ 8
- 3
react/features/device-selection/functions.js ファイルの表示

@@ -15,6 +15,11 @@ import {
15 15
 } from '../base/devices';
16 16
 import JitsiMeetJS from '../base/lib-jitsi-meet';
17 17
 import { toState } from '../base/redux';
18
+import {
19
+    getUserSelectedCameraDeviceId,
20
+    getUserSelectedMicDeviceId,
21
+    getUserSelectedOutputDeviceId
22
+} from '../base/settings';
18 23
 
19 24
 /**
20 25
  * Returns the properties for the device selection dialog from Redux state.
@@ -38,9 +43,9 @@ export function getDeviceSelectionDialogProps(stateful: Object | Function) {
38 43
     // on welcome page we also show only what we have saved as user selected devices
39 44
     if (!conference) {
40 45
         disableAudioInputChange = false;
41
-        selectedAudioInputId = settings.userSelectedMicDeviceId;
42
-        selectedAudioOutputId = settings.userSelectedAudioOutputDeviceId;
43
-        selectedVideoInputId = settings.userSelectedCameraDeviceId;
46
+        selectedAudioInputId = getUserSelectedMicDeviceId(state);
47
+        selectedAudioOutputId = getUserSelectedOutputDeviceId(state);
48
+        selectedVideoInputId = getUserSelectedCameraDeviceId(state);
44 49
     }
45 50
 
46 51
     // we fill the device selection dialog with the devices that are currently

読み込み中…
キャンセル
保存