added basic live streaming
This commit is contained in:
parent
c67b79af28
commit
fb3f94f36b
|
|
@ -1,10 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
||||||
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
||||||
export const Modals = () => (
|
export const Modals = () => {
|
||||||
<>
|
const { modals } = useUIState();
|
||||||
<DeviceSelectModal />
|
|
||||||
</>
|
return (
|
||||||
);
|
<>
|
||||||
|
<DeviceSelectModal />
|
||||||
|
{modals.map((ModalComponent) => (
|
||||||
|
<ModalComponent key={ModalComponent.name} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Modals;
|
export default Modals;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ function App({ Component, pageProps }) {
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<Component
|
<Component
|
||||||
asides={App.asides}
|
asides={App.asides}
|
||||||
|
modals={App.modals}
|
||||||
customTrayComponent={App.customTrayComponent}
|
customTrayComponent={App.customTrayComponent}
|
||||||
customAppComponent={App.customAppComponent}
|
customAppComponent={App.customAppComponent}
|
||||||
{...pageProps}
|
{...pageProps}
|
||||||
|
|
@ -33,6 +34,7 @@ App.propTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
App.asides = [];
|
App.asides = [];
|
||||||
|
App.modals = [];
|
||||||
App.customTrayComponent = null;
|
App.customTrayComponent = null;
|
||||||
App.customAppComponent = null;
|
App.customAppComponent = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export default function Index({
|
||||||
isConfigured = false,
|
isConfigured = false,
|
||||||
predefinedRoom = false,
|
predefinedRoom = false,
|
||||||
asides,
|
asides,
|
||||||
|
modals,
|
||||||
customTrayComponent,
|
customTrayComponent,
|
||||||
customAppComponent,
|
customAppComponent,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -99,7 +100,11 @@ export default function Index({
|
||||||
* Main call UI
|
* Main call UI
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<UIStateProvider asides={asides} customTrayComponent={customTrayComponent}>
|
<UIStateProvider
|
||||||
|
asides={asides}
|
||||||
|
modals={modals}
|
||||||
|
customTrayComponent={customTrayComponent}
|
||||||
|
>
|
||||||
<CallProvider domain={domain} room={roomName} token={token}>
|
<CallProvider domain={domain} room={roomName} token={token}>
|
||||||
<ParticipantsProvider>
|
<ParticipantsProvider>
|
||||||
<TracksProvider>
|
<TracksProvider>
|
||||||
|
|
@ -120,6 +125,7 @@ Index.propTypes = {
|
||||||
predefinedRoom: PropTypes.bool,
|
predefinedRoom: PropTypes.bool,
|
||||||
domain: PropTypes.string,
|
domain: PropTypes.string,
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
asides: PropTypes.arrayOf(PropTypes.func),
|
||||||
|
modals: PropTypes.arrayOf(PropTypes.func),
|
||||||
customTrayComponent: PropTypes.node,
|
customTrayComponent: PropTypes.node,
|
||||||
customAppComponent: PropTypes.node,
|
customAppComponent: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import Aside from '@dailyjs/shared/components/Aside';
|
|
||||||
import { Button } from '@dailyjs/shared/components/Button';
|
|
||||||
import { TextInput } from '@dailyjs/shared/components/Input';
|
|
||||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
|
||||||
import { useChat } from '../../contexts/ChatProvider';
|
|
||||||
import { useMessageSound } from '../../hooks/useMessageSound';
|
|
||||||
|
|
||||||
export const CHAT_ASIDE = 'chat';
|
|
||||||
|
|
||||||
export const ChatAside = () => {
|
|
||||||
const { showAside, setShowAside } = useUIState();
|
|
||||||
const { sendMessage, chatHistory, hasNewMessages, setHasNewMessages } =
|
|
||||||
useChat();
|
|
||||||
const [newMessage, setNewMessage] = useState('');
|
|
||||||
const playMessageSound = useMessageSound();
|
|
||||||
|
|
||||||
const chatWindowRef = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Clear out any new message notifications if we're showing the chat screen
|
|
||||||
if (showAside === CHAT_ASIDE) {
|
|
||||||
setHasNewMessages(false);
|
|
||||||
}
|
|
||||||
}, [showAside, chatHistory.length, setHasNewMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasNewMessages && showAside !== CHAT_ASIDE) {
|
|
||||||
playMessageSound();
|
|
||||||
}
|
|
||||||
}, [playMessageSound, showAside, hasNewMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatWindowRef.current) {
|
|
||||||
chatWindowRef.current.scrollTop = chatWindowRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [chatHistory?.length]);
|
|
||||||
|
|
||||||
if (!showAside || showAside !== CHAT_ASIDE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Aside onClose={() => setShowAside(false)}>
|
|
||||||
<div className="messages-container" ref={chatWindowRef}>
|
|
||||||
{chatHistory.map((chatItem) => (
|
|
||||||
<div
|
|
||||||
className={chatItem.isLocal ? 'message local' : 'message'}
|
|
||||||
key={chatItem.id}
|
|
||||||
>
|
|
||||||
<span className="content">{chatItem.message}</span>
|
|
||||||
<span className="sender">{chatItem.sender}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<footer className="chat-footer">
|
|
||||||
<TextInput
|
|
||||||
value={newMessage}
|
|
||||||
placeholder="Type message here"
|
|
||||||
variant="transparent"
|
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="send-button"
|
|
||||||
variant="transparent"
|
|
||||||
disabled={!newMessage}
|
|
||||||
onClick={() => {
|
|
||||||
sendMessage(newMessage);
|
|
||||||
setNewMessage('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
<style jsx>{`
|
|
||||||
.messages-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin: var(--spacing-xxs);
|
|
||||||
padding: var(--spacing-xxs);
|
|
||||||
background: var(--gray-wash);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.local {
|
|
||||||
background: var(--gray-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.local .sender {
|
|
||||||
color: var(--primary-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
color: var(--text-mid);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-footer {
|
|
||||||
flex-flow: row nowrap;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: var(--spacing-xxs) 0;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
border-top: 1px solid var(--gray-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-footer :global(.input-container) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-footer :global(.input-container input) {
|
|
||||||
padding-right: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-footer :global(.send-button) {
|
|
||||||
padding: 0 var(--spacing-xs);
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</Aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatAside;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { ChatAside as default } from './ChatAside';
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@dailyjs/shared/components/Button';
|
||||||
|
import Field from '@dailyjs/shared/components/Field';
|
||||||
|
import { TextInput } from '@dailyjs/shared/components/Input';
|
||||||
|
import Modal from '@dailyjs/shared/components/Modal';
|
||||||
|
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||||
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
||||||
|
export const LIVE_STREAMING_MODAL = 'live-streaming';
|
||||||
|
|
||||||
|
export const LiveStreamingModal = () => {
|
||||||
|
const { callObject } = useCallState();
|
||||||
|
const { currentModals, closeModal } = useUIState();
|
||||||
|
const [rtmpUrl, setRtmpUrl] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Live stream"
|
||||||
|
isOpen={currentModals[LIVE_STREAMING_MODAL]}
|
||||||
|
onClose={() => closeModal(LIVE_STREAMING_MODAL)}
|
||||||
|
>
|
||||||
|
<Field label="Enter room to join">
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
placeholder="RTMP URL"
|
||||||
|
required
|
||||||
|
onChange={(e) => setRtmpUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={!rtmpUrl}
|
||||||
|
onClick={() => callObject.startLiveStreaming({ rtmpUrl })}
|
||||||
|
>
|
||||||
|
Start live streaming
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => callObject.stopLiveStreaming()}>
|
||||||
|
Stop live streaming
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LiveStreamingModal;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { LiveStreamingModal as default } from './LiveStreamingModal';
|
||||||
|
export { LiveStreamingModal } from './LiveStreamingModal';
|
||||||
|
export { LIVE_STREAMING_MODAL } from './LiveStreamingModal';
|
||||||
|
|
@ -2,24 +2,20 @@ import React from 'react';
|
||||||
|
|
||||||
import { TrayButton } from '@dailyjs/shared/components/Tray';
|
import { TrayButton } from '@dailyjs/shared/components/Tray';
|
||||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
import { ReactComponent as IconChat } from '@dailyjs/shared/icons/chat-md.svg';
|
import { ReactComponent as IconStream } from '@dailyjs/shared/icons/streaming-md.svg';
|
||||||
import { useChat } from '../../contexts/ChatProvider';
|
|
||||||
import { CHAT_ASIDE } from '../ChatAside/ChatAside';
|
import { LIVE_STREAMING_MODAL } from '../LiveStreamingModal';
|
||||||
|
|
||||||
export const Tray = () => {
|
export const Tray = () => {
|
||||||
const { toggleAside } = useUIState();
|
const { openModal } = useUIState();
|
||||||
const { hasNewMessages } = useChat();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TrayButton
|
<TrayButton
|
||||||
label="Chat"
|
label="Stream"
|
||||||
bubble={hasNewMessages}
|
onClick={() => openModal(LIVE_STREAMING_MODAL)}
|
||||||
onClick={() => {
|
|
||||||
toggleAside(CHAT_ASIDE);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconChat />
|
<IconStream />
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import React from 'react';
|
||||||
import App from '@dailyjs/basic-call/pages/_app';
|
import App from '@dailyjs/basic-call/pages/_app';
|
||||||
import AppWithChat from '../components/App';
|
import AppWithChat from '../components/App';
|
||||||
|
|
||||||
import ChatAside from '../components/ChatAside';
|
import { LiveStreamingModal } from '../components/LiveStreamingModal';
|
||||||
import Tray from '../components/Tray';
|
import Tray from '../components/Tray';
|
||||||
|
|
||||||
App.asides = [ChatAside];
|
App.modals = [LiveStreamingModal];
|
||||||
App.customAppComponent = <AppWithChat />;
|
App.customAppComponent = <AppWithChat />;
|
||||||
App.customTrayComponent = <Tray />;
|
App.customTrayComponent = <Tray />;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@ import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { DeviceSelect } from '../DeviceSelect';
|
import { DeviceSelect } from '../DeviceSelect';
|
||||||
|
|
||||||
|
export const DEVICE_MODAL = 'device';
|
||||||
|
|
||||||
export const DeviceSelectModal = () => {
|
export const DeviceSelectModal = () => {
|
||||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
const { currentModals, closeModal } = useUIState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Select your device"
|
title="Select your device"
|
||||||
isOpen={showDeviceModal}
|
isOpen={currentModals[DEVICE_MODAL]}
|
||||||
onClose={() => setShowDeviceModal(false)}
|
onClose={() => closeModal(DEVICE_MODAL)}
|
||||||
actions={[
|
actions={[
|
||||||
<Button fullWidth variant="outline">
|
<Button fullWidth variant="outline">
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { DeviceSelectModal as default } from './DeviceSelectModal';
|
export { DeviceSelectModal as default } from './DeviceSelectModal';
|
||||||
export { DeviceSelectModal } from './DeviceSelectModal';
|
export { DeviceSelectModal } from './DeviceSelectModal';
|
||||||
|
export { DEVICE_MODAL } from './DeviceSelectModal';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import Button from '@dailyjs/shared/components/Button';
|
import Button from '@dailyjs/shared/components/Button';
|
||||||
|
import { DEVICE_MODAL } from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
||||||
import { TextInput } from '@dailyjs/shared/components/Input';
|
import { TextInput } from '@dailyjs/shared/components/Input';
|
||||||
import Loader from '@dailyjs/shared/components/Loader';
|
import Loader from '@dailyjs/shared/components/Loader';
|
||||||
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
|
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
|
||||||
|
|
@ -33,7 +34,7 @@ export const HairCheck = () => {
|
||||||
const { localParticipant } = useParticipants();
|
const { localParticipant } = useParticipants();
|
||||||
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
|
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
|
||||||
useMediaDevices();
|
useMediaDevices();
|
||||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
const { openModal } = useUIState();
|
||||||
const [waiting, setWaiting] = useState(false);
|
const [waiting, setWaiting] = useState(false);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [denied, setDenied] = useState();
|
const [denied, setDenied] = useState();
|
||||||
|
|
@ -143,7 +144,7 @@ export const HairCheck = () => {
|
||||||
className="device-button"
|
className="device-button"
|
||||||
size="medium-square"
|
size="medium-square"
|
||||||
variant="blur"
|
variant="blur"
|
||||||
onClick={() => setShowDeviceModal(!showDeviceModal)}
|
onClick={() => openModal(DEVICE_MODAL)}
|
||||||
>
|
>
|
||||||
<IconSettings />
|
<IconSettings />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PEOPLE_ASIDE } from '@dailyjs/shared/components/Aside/PeopleAside';
|
import { PEOPLE_ASIDE } from '@dailyjs/shared/components/Aside/PeopleAside';
|
||||||
|
import { DEVICE_MODAL } from '@dailyjs/shared/components/DeviceSelectModal';
|
||||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||||
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
@ -14,7 +15,7 @@ import { Tray, TrayButton } from './Tray';
|
||||||
|
|
||||||
export const BasicTray = () => {
|
export const BasicTray = () => {
|
||||||
const { callObject, leave } = useCallState();
|
const { callObject, leave } = useCallState();
|
||||||
const { customTrayComponent, setShowDeviceModal, toggleAside } = useUIState();
|
const { customTrayComponent, openModal, toggleAside } = useUIState();
|
||||||
const { isCamMuted, isMicMuted } = useMediaDevices();
|
const { isCamMuted, isMicMuted } = useMediaDevices();
|
||||||
|
|
||||||
const toggleCamera = (newState) => {
|
const toggleCamera = (newState) => {
|
||||||
|
|
@ -43,7 +44,7 @@ export const BasicTray = () => {
|
||||||
>
|
>
|
||||||
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
|
<TrayButton label="Settings" onClick={() => openModal(DEVICE_MODAL)}>
|
||||||
<IconSettings />
|
<IconSettings />
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,33 @@
|
||||||
import React, { useCallback, createContext, useContext, useState } from 'react';
|
import React, { useCallback, createContext, useContext, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||||
|
|
||||||
export const UIStateContext = createContext();
|
export const UIStateContext = createContext();
|
||||||
|
|
||||||
export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
export const UIStateProvider = ({
|
||||||
const [showDeviceModal, setShowDeviceModal] = useState(false);
|
asides,
|
||||||
|
modals,
|
||||||
|
customTrayComponent,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
const [showAside, setShowAside] = useState();
|
const [showAside, setShowAside] = useState();
|
||||||
|
const [activeModals, setActiveModals] = useState({});
|
||||||
|
|
||||||
|
const openModal = useCallback((modalName) => {
|
||||||
|
setActiveModals((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
[modalName]: true,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback((modalName) => {
|
||||||
|
setActiveModals((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
[modalName]: false,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentModals = useDeepCompareMemo(() => activeModals, [activeModals]);
|
||||||
|
|
||||||
const toggleAside = useCallback((newAside) => {
|
const toggleAside = useCallback((newAside) => {
|
||||||
setShowAside((p) => (p === newAside ? null : newAside));
|
setShowAside((p) => (p === newAside ? null : newAside));
|
||||||
|
|
@ -15,9 +37,11 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
||||||
<UIStateContext.Provider
|
<UIStateContext.Provider
|
||||||
value={{
|
value={{
|
||||||
asides,
|
asides,
|
||||||
|
modals,
|
||||||
customTrayComponent,
|
customTrayComponent,
|
||||||
showDeviceModal,
|
openModal,
|
||||||
setShowDeviceModal,
|
closeModal,
|
||||||
|
currentModals,
|
||||||
toggleAside,
|
toggleAside,
|
||||||
showAside,
|
showAside,
|
||||||
setShowAside,
|
setShowAside,
|
||||||
|
|
@ -31,6 +55,7 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
||||||
UIStateProvider.propTypes = {
|
UIStateProvider.propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
asides: PropTypes.arrayOf(PropTypes.func),
|
||||||
|
modals: PropTypes.arrayOf(PropTypes.func),
|
||||||
customTrayComponent: PropTypes.node,
|
customTrayComponent: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 952 B |
Loading…
Reference in New Issue