1 | /** |
---|
2 | * External dependencies |
---|
3 | */ |
---|
4 | import apiFetch from '@wordpress/api-fetch'; |
---|
5 | import { usePrevious } from '@wordpress/compose'; |
---|
6 | import { store as coreStore } from '@wordpress/core-data'; |
---|
7 | import { useSelect, useDispatch } from '@wordpress/data'; |
---|
8 | import { store as editorStore } from '@wordpress/editor'; |
---|
9 | import { useEffect, useState, useCallback } from '@wordpress/element'; |
---|
10 | import { __ } from '@wordpress/i18n'; |
---|
11 | import debugFactory from 'debug'; |
---|
12 | /** |
---|
13 | * Internal dependencies |
---|
14 | */ |
---|
15 | import { getVideoPressUrl } from '../../../lib/url'; |
---|
16 | import { uploadTrackForGuid } from '../../../lib/video-tracks'; |
---|
17 | import { UploadTrackDataProps } from '../../../lib/video-tracks/types'; |
---|
18 | import { |
---|
19 | WPComV2VideopressGetMetaEndpointResponseProps, |
---|
20 | WPComV2VideopressPostMetaEndpointBodyProps, |
---|
21 | } from '../../../types'; |
---|
22 | import extractVideoChapters from '../../../utils/extract-video-chapters'; |
---|
23 | import generateChaptersFile from '../../../utils/generate-chapters-file'; |
---|
24 | import { snakeToCamel } from '../../../utils/map-object-keys-to-camel-case'; |
---|
25 | import { |
---|
26 | VideoBlockAttributes, |
---|
27 | VideoBlockSetAttributesProps, |
---|
28 | VideoId, |
---|
29 | } from '../../blocks/video/types'; |
---|
30 | import useVideoData from '../use-video-data'; |
---|
31 | import { VideoDataProps } from '../use-video-data/types'; |
---|
32 | import { UseSyncMediaProps, UseSyncMediaOptionsProps, ArrangeTracksAttributesProps } from './types'; |
---|
33 | |
---|
34 | const debug = debugFactory( 'videopress:video:use-sync-media' ); |
---|
35 | |
---|
36 | /** |
---|
37 | * Hook to update the media data by hitting the VideoPress API. |
---|
38 | * |
---|
39 | * @param {VideoId} id - Media ID. |
---|
40 | * @returns {Function} Update Promise handler. |
---|
41 | */ |
---|
42 | export default function useMediaDataUpdate( id: VideoId ) { |
---|
43 | const updateMediaItem = data => { |
---|
44 | return new Promise( ( resolve, reject ) => { |
---|
45 | apiFetch( { |
---|
46 | path: '/wpcom/v2/videopress/meta', |
---|
47 | method: 'POST', |
---|
48 | data: { id, ...data }, |
---|
49 | } ) |
---|
50 | .then( ( result: WPComV2VideopressGetMetaEndpointResponseProps ) => { |
---|
51 | if ( 200 !== result?.data ) { |
---|
52 | return reject( result ); |
---|
53 | } |
---|
54 | resolve( result ); |
---|
55 | } ) |
---|
56 | .catch( reject ); |
---|
57 | } ); |
---|
58 | }; |
---|
59 | |
---|
60 | return updateMediaItem; |
---|
61 | } |
---|
62 | |
---|
63 | /* |
---|
64 | * Fields list to keep in sync with block attributes. |
---|
65 | */ |
---|
66 | const videoFieldsToUpdate = [ |
---|
67 | 'title', |
---|
68 | 'description', |
---|
69 | 'privacy_setting', |
---|
70 | 'rating', |
---|
71 | 'allow_download', |
---|
72 | 'display_embed', |
---|
73 | 'is_private', |
---|
74 | ]; |
---|
75 | |
---|
76 | /* |
---|
77 | * Map object from video field name to block attribute name. |
---|
78 | * Only register those fields that have a different attribute name. |
---|
79 | */ |
---|
80 | const mapFieldsToAttributes = { |
---|
81 | privacy_setting: 'privacySetting', |
---|
82 | allow_download: 'allowDownload', |
---|
83 | display_embed: 'displayEmbed', |
---|
84 | is_private: 'isPrivate', |
---|
85 | }; |
---|
86 | |
---|
87 | /** |
---|
88 | * Re-arrange the tracks to match the block attribute format. |
---|
89 | * Also, check if the tracks is out of sync with the media item. |
---|
90 | * |
---|
91 | * @param {VideoDataProps} videoData - Video data, provided by server. |
---|
92 | * @param {VideoBlockAttributes} attributes - Block attributes. |
---|
93 | * @returns {VideoBlockAttributes} Video block attributes. |
---|
94 | */ |
---|
95 | function arrangeTracksAttributes( |
---|
96 | videoData: VideoDataProps, |
---|
97 | attributes: VideoBlockAttributes |
---|
98 | ): ArrangeTracksAttributesProps { |
---|
99 | if ( ! videoData?.tracks ) { |
---|
100 | return [ [], false ]; |
---|
101 | } |
---|
102 | |
---|
103 | const tracks = []; |
---|
104 | let tracksOufOfSync = false; |
---|
105 | |
---|
106 | Object.keys( videoData.tracks ).forEach( kind => { |
---|
107 | for ( const srcLang in videoData.tracks[ kind ] ) { |
---|
108 | const track = videoData.tracks[ kind ][ srcLang ]; |
---|
109 | const trackExistsInBlock = attributes.tracks.find( t => { |
---|
110 | return ( |
---|
111 | t.kind === kind && t.srcLang === srcLang && t.src === track.src && t.label === track.label |
---|
112 | ); |
---|
113 | } ); |
---|
114 | |
---|
115 | if ( ! trackExistsInBlock ) { |
---|
116 | debug( 'Track %o is out of sync. Set tracks attr', track.src ); |
---|
117 | tracksOufOfSync = true; |
---|
118 | } |
---|
119 | |
---|
120 | tracks.push( { |
---|
121 | src: track.src, |
---|
122 | kind, |
---|
123 | srcLang, |
---|
124 | label: track.label, |
---|
125 | } ); |
---|
126 | } |
---|
127 | } ); |
---|
128 | |
---|
129 | return [ tracks, tracksOufOfSync ]; |
---|
130 | } |
---|
131 | |
---|
132 | /** |
---|
133 | * React hook to keep the data in-sync |
---|
134 | * between the media item and the block attributes. |
---|
135 | * |
---|
136 | * @param {object} attributes - Block attributes. |
---|
137 | * @param {Function} setAttributes - Block attributes setter. |
---|
138 | * @param {UseSyncMediaOptionsProps} options - Options. |
---|
139 | * @returns {UseSyncMediaProps} Hook API object. |
---|
140 | */ |
---|
141 | export function useSyncMedia( |
---|
142 | attributes: VideoBlockAttributes, |
---|
143 | setAttributes: VideoBlockSetAttributesProps, |
---|
144 | options: UseSyncMediaOptionsProps |
---|
145 | ): UseSyncMediaProps { |
---|
146 | const { id, guid } = attributes; |
---|
147 | const { videoData, isRequestingVideoData } = useVideoData( { id, guid } ); |
---|
148 | |
---|
149 | const isSaving = useSelect( select => select( editorStore ).isSavingPost(), [] ); |
---|
150 | const wasSaving = usePrevious( isSaving ); |
---|
151 | const invalidateResolution = useDispatch( coreStore ).invalidateResolution; |
---|
152 | |
---|
153 | const [ initialState, setState ] = useState( {} ); |
---|
154 | |
---|
155 | const updateInitialState = useCallback( ( data: VideoDataProps ) => { |
---|
156 | setState( current => ( { ...current, ...data } ) ); |
---|
157 | }, [] ); |
---|
158 | |
---|
159 | /* |
---|
160 | * Media data => Block attributes (update) |
---|
161 | * |
---|
162 | * Populate block attributes with the media data, |
---|
163 | * provided by the VideoPress API (useVideoData hook), |
---|
164 | * when the block is mounted. |
---|
165 | */ |
---|
166 | useEffect( () => { |
---|
167 | if ( isRequestingVideoData ) { |
---|
168 | return; |
---|
169 | } |
---|
170 | |
---|
171 | if ( ! videoData || Object.keys( videoData ).length === 0 ) { |
---|
172 | return; |
---|
173 | } |
---|
174 | |
---|
175 | const attributesToUpdate: VideoBlockAttributes = {}; |
---|
176 | |
---|
177 | // Build an object with video data to use for the initial state. |
---|
178 | const initialVideoData = videoFieldsToUpdate.reduce( |
---|
179 | ( acc, key ) => { |
---|
180 | if ( typeof videoData[ key ] === 'undefined' ) { |
---|
181 | return acc; |
---|
182 | } |
---|
183 | |
---|
184 | let videoDataValue = videoData[ key ]; |
---|
185 | |
---|
186 | // Cast privacy_setting to number to match the block attribute type. |
---|
187 | if ( 'privacy_setting' === key ) { |
---|
188 | videoDataValue = Number( videoDataValue ); |
---|
189 | } |
---|
190 | |
---|
191 | acc[ key ] = videoDataValue; |
---|
192 | const attrName = snakeToCamel( key ); |
---|
193 | |
---|
194 | if ( videoDataValue !== attributes[ attrName ] ) { |
---|
195 | debug( |
---|
196 | '%o is out of sync. Updating %o attr from %o to %o ', |
---|
197 | key, |
---|
198 | attrName, |
---|
199 | attributes[ attrName ], |
---|
200 | videoDataValue |
---|
201 | ); |
---|
202 | attributesToUpdate[ attrName ] = videoDataValue; |
---|
203 | } |
---|
204 | return acc; |
---|
205 | }, |
---|
206 | { |
---|
207 | tracks: [], |
---|
208 | } |
---|
209 | ); |
---|
210 | |
---|
211 | updateInitialState( initialVideoData ); |
---|
212 | |
---|
213 | if ( ! Object.keys( initialVideoData ).length ) { |
---|
214 | return; |
---|
215 | } |
---|
216 | |
---|
217 | const [ tracks, tracksOufOfSync ] = arrangeTracksAttributes( videoData, attributes ); |
---|
218 | |
---|
219 | // Sync video tracks if needed. |
---|
220 | if ( tracksOufOfSync ) { |
---|
221 | attributesToUpdate.tracks = tracks; |
---|
222 | } |
---|
223 | |
---|
224 | if ( ! Object.keys( attributesToUpdate ).length ) { |
---|
225 | return; |
---|
226 | } |
---|
227 | |
---|
228 | debug( 'Updating attributes: ', attributesToUpdate ); |
---|
229 | setAttributes( attributesToUpdate ); |
---|
230 | }, [ videoData, isRequestingVideoData ] ); |
---|
231 | |
---|
232 | const updateMediaHandler = useMediaDataUpdate( id ); |
---|
233 | |
---|
234 | /* |
---|
235 | * Block attributes => Media data (sync) |
---|
236 | * |
---|
237 | * Compare the current attribute values of the block |
---|
238 | * with the initial state, |
---|
239 | * and sync the media data if it detects changes on it |
---|
240 | * (via the VideoPress API) when the post saves. |
---|
241 | */ |
---|
242 | useEffect( () => { |
---|
243 | if ( ! isSaving || wasSaving ) { |
---|
244 | return; |
---|
245 | } |
---|
246 | |
---|
247 | debug( 'Saving post action detected' ); |
---|
248 | |
---|
249 | if ( ! attributes?.id ) { |
---|
250 | return; |
---|
251 | } |
---|
252 | |
---|
253 | /* |
---|
254 | * Filter the attributes that have changed their values, |
---|
255 | * based on the initial state. |
---|
256 | */ |
---|
257 | const dataToUpdate: WPComV2VideopressPostMetaEndpointBodyProps = videoFieldsToUpdate.reduce( |
---|
258 | ( acc, key ) => { |
---|
259 | const attrName = mapFieldsToAttributes[ key ] || key; |
---|
260 | const stateValue = initialState[ key ]; |
---|
261 | const attrValue = attributes[ attrName ]; |
---|
262 | |
---|
263 | if ( initialState[ key ] !== attributes[ attrName ] ) { |
---|
264 | debug( 'Field to sync %o: %o => %o: %o', key, stateValue, attrName, attrValue ); |
---|
265 | acc[ key ] = attributes[ attrName ]; |
---|
266 | } |
---|
267 | return acc; |
---|
268 | }, |
---|
269 | {} |
---|
270 | ); |
---|
271 | |
---|
272 | // When nothing to update, bail out early. |
---|
273 | if ( ! Object.keys( dataToUpdate ).length ) { |
---|
274 | return debug( 'No data to sync. Bail early' ); |
---|
275 | } |
---|
276 | |
---|
277 | debug( 'Syncing data: ', dataToUpdate ); |
---|
278 | |
---|
279 | // Sync the block attributes data with the video data |
---|
280 | updateMediaHandler( dataToUpdate ).then( () => { |
---|
281 | // Update local state with fresh video data. |
---|
282 | updateInitialState( dataToUpdate ); |
---|
283 | |
---|
284 | // | Video Chapters feature | |
---|
285 | const chapters = extractVideoChapters( dataToUpdate?.description ); |
---|
286 | |
---|
287 | if ( |
---|
288 | options.isAutoGeneratedChapter && |
---|
289 | attributes?.guid && |
---|
290 | dataToUpdate?.description?.length && |
---|
291 | chapters?.length |
---|
292 | ) { |
---|
293 | debug( 'Auto-generated chapter detected. Processing...' ); |
---|
294 | const track: UploadTrackDataProps = { |
---|
295 | label: __( 'English (auto-generated)', 'jetpack-videopress-pkg' ), |
---|
296 | srcLang: 'en', |
---|
297 | kind: 'chapters', |
---|
298 | tmpFile: generateChaptersFile( dataToUpdate.description ), |
---|
299 | }; |
---|
300 | |
---|
301 | debug( 'Auto-generated track: %o', track ); |
---|
302 | |
---|
303 | uploadTrackForGuid( track, guid ).then( ( src: string ) => { |
---|
304 | const autoGeneratedTrackIndex = attributes.tracks.findIndex( |
---|
305 | t => t.kind === 'chapters' && t.srcLang === 'en' |
---|
306 | ); |
---|
307 | |
---|
308 | const uploadedTrack = { |
---|
309 | ...track, |
---|
310 | src, |
---|
311 | }; |
---|
312 | |
---|
313 | const tracks = [ ...attributes.tracks ]; |
---|
314 | |
---|
315 | if ( autoGeneratedTrackIndex > -1 ) { |
---|
316 | debug( 'Updating %o auto-generated track', uploadedTrack.src ); |
---|
317 | tracks[ autoGeneratedTrackIndex ] = uploadedTrack; |
---|
318 | } else { |
---|
319 | debug( 'Adding auto-generated %o track', uploadedTrack.src ); |
---|
320 | tracks.push( uploadedTrack ); |
---|
321 | } |
---|
322 | |
---|
323 | // Update block track attribute |
---|
324 | setAttributes( { tracks } ); |
---|
325 | |
---|
326 | const videoPressUrl = getVideoPressUrl( guid, attributes ); |
---|
327 | invalidateResolution( 'getEmbedPreview', [ videoPressUrl ] ); |
---|
328 | } ); |
---|
329 | } else { |
---|
330 | const videoPressUrl = getVideoPressUrl( guid, attributes ); |
---|
331 | invalidateResolution( 'getEmbedPreview', [ videoPressUrl ] ); |
---|
332 | } |
---|
333 | } ); |
---|
334 | }, [ |
---|
335 | isSaving, |
---|
336 | wasSaving, |
---|
337 | updateMediaHandler, |
---|
338 | updateInitialState, |
---|
339 | attributes, |
---|
340 | initialState, |
---|
341 | invalidateResolution, |
---|
342 | videoFieldsToUpdate, |
---|
343 | ] ); |
---|
344 | |
---|
345 | return { |
---|
346 | forceInitialState: updateInitialState, |
---|
347 | videoData, |
---|
348 | isRequestingVideoData, |
---|
349 | }; |
---|
350 | } |
---|