feature (chromecast): wip for chromecast support

This commit is contained in:
Mickael Kerjean 2023-04-18 00:01:32 +10:00
parent 2be99ac611
commit 07e77e969d
7 changed files with 304 additions and 188 deletions

View file

@ -135,14 +135,6 @@ export function ViewerPageComponent({ error, subscribe, unsubscribe, match, loca
return history.listen(() => {})
}, [path]);
useEffect(() => {
return () => {
const context = Chromecast.context();
if (!context) return;
context.endCurrentSession();
};
}, []);
return (
<div className="component_page_viewerpage">
<BreadCrumb needSaving={state.needSaving} className="breadcrumb" path={path} />

View file

@ -14,8 +14,11 @@ export function AudioPlayer({ filename, data }) {
const [isLoading, setIsLoading] = useState(true);
const [purcentLoading, setPurcentLoading] = useState(0);
const [volume, setVolume] = useState(settings_get("volume") === null ? 50 : settings_get("volume"));
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isChromecast, setIsChromecast] = useState(false);
const [error, setError] = useState(null);
const [render, setRender] = useState(0);
const wavesurfer = useRef(null);
useEffect(() => {
@ -28,18 +31,18 @@ export function AudioPlayer({ filename, data }) {
height: 200,
barWidth: 1,
});
window.wavesurfer = wavesurfer.current; // TODO: remove this
wavesurfer.current.load(data);
let $currentTime = document.getElementById("currentTime");
let $totalDuration = document.getElementById("totalDuration");
wavesurfer.current.on("ready", () => {
setPurcentLoading(100);
setIsLoading(false);
wavesurfer.current.setVolume(volume / 100);
$totalDuration.innerHTML = formatTimecode(wavesurfer.current.getDuration());
setDuration(wavesurfer.current.getDuration());
});
wavesurfer.current.on("audioprocess", () => {
$currentTime.innerHTML = formatTimecode(wavesurfer.current.getCurrentTime());
const t = wavesurfer.current.getCurrentTime()
_currentTime = t;
setCurrentTime(t);
});
wavesurfer.current.on("loading", (n) => {
setPurcentLoading(n);
@ -48,106 +51,53 @@ export function AudioPlayer({ filename, data }) {
setIsLoading(false);
setError(err);
});
wavesurfer.current.on("seek", (s) => {
const media = Chromecast.media();
if (!media) return;
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = parseInt(s*wavesurfer.current.getDuration());
media.seek(seekRequest);
});
return () => wavesurfer.current.destroy();
}, []);
}, [data]);
useEffect(() => {
const onKeyPressHandler = (e) => {
if(e.code !== "Space") {
return
}
// TODO: write shortcut
isPlaying ? onPause(e) : onPlay(e);
};
window.addEventListener("keypress", onKeyPressHandler);
return () => window.removeEventListener("keypress", onKeyPressHandler);
}, [isPlaying, isChromecast])
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTING:
setIsChromecast(true);
setIsLoading(true);
break;
case cast.framework.SessionState.SESSION_START_FAILED:
setIsChromecast(false);
setIsLoading(false);
break;
case cast.framework.SessionState.SESSION_STARTED:
chromecastHandler()
const session = Chromecast.session();
if (session) setVolume(session.getVolume() * 100);
break;
case cast.framework.SessionState.SESSION_ENDING:
wavesurfer.current.setMute(false);
setVolume(wavesurfer.current.getVolume() * 100);
setIsChromecast(false);
break;
}
};
const chromecastHandler = () => {
setIsLoading(true);
const link = Chromecast.createLink(data);
const media = new chrome.cast.media.MediaInfo(
link,
getMimeType(data),
);
media.metadata = new chrome.cast.media.MusicTrackMediaMetadata()
media.metadata.title = filename.substr(0, filename.lastIndexOf(filepath.extname(filename)));
media.metadata.subtitle = CONFIG.name;
media.metadata.albumName = CONFIG.name;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/music.png"),
];
const session = Chromecast.session();
if (!session) return;
Chromecast.createRequest(media)
.then((req) => {
req.currentTime = parseInt(wavesurfer.current.getCurrentTime());
return session.loadMedia(req)
})
.then(() => {
setIsPlaying(true);
setIsLoading(false);
wavesurfer.current.play();
wavesurfer.current.setMute(true);
const media = Chromecast.media();
if (!media) return;
wavesurfer.current.seekTo(media.getEstimatedTime() / wavesurfer.current.getDuration());
media.addUpdateListener(chromecastAlive);
}).catch((err) => {
console.error(err);
notify.send(t("Cannot establish a connection"), "error");
setIsChromecast(false);
setIsLoading(false);
});
}
const chromecastAlive = (isAlive) => {
if (isAlive) return;
const session = Chromecast.session();
if (session) {
session.endSession();
wavesurfer.current.setMute(false);
}
};
}, [isPlaying, isChromecast]);
useEffect(() => {
const context = Chromecast.context();
if (!context) return;
chromecastAlive(false);
document.getElementById("chromecast-target").append(document.createElement("google-cast-launcher"));
_currentTime = 0;
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTING:
setIsChromecast(true);
setIsLoading(true);
break;
case cast.framework.SessionState.SESSION_START_FAILED:
setIsChromecast(false);
setIsLoading(false);
break;
case cast.framework.SessionState.SESSION_STARTED:
chromecastLoader()
break;
case cast.framework.SessionState.SESSION_ENDING:
setIsChromecast(false);
// console.log("ENDING seekTo", _currentTime, duration, wavesurfer.current.getDuration(), _currentTime / wavesurfer.current.getDuration());
wavesurfer.current.seekTo(_currentTime / wavesurfer.current.getDuration());
// TODO: reset volume setVolume(wavesurfer.current.getVolume() * 100) --> not working
wavesurfer.current.setMute(false);
const media = Chromecast.media();
if (media && media.playerState === "PLAYING") wavesurfer.current.play();
else if (media && media.playerState === "PAUSED") wavesurfer.current.pause();
break;
}
};
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
@ -157,13 +107,92 @@ export function AudioPlayer({ filename, data }) {
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
const media = Chromecast.media();
if (!media) return
media.removeUpdateListener(chromecastAlive);
chromecastAlive(false);
};
}, []);
useEffect(() => {
if (!wavesurfer) return;
const onSeek = (s) => {
// console.log("ON SEEK", isChromecast, isLoading);
if (isChromecast === false) return
else if (s * duration === _currentTime) {
// wavesurfer trigger a seek event when trying to synchronise the remote to the local
// which we want to ignore as we're only interested in user requested seek
return;
}
const media = Chromecast.media();
if (!media) return;
wavesurfer.current.pause();
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = s*duration;
media.seek(seekRequest);
}
wavesurfer.current.on("seek", onSeek);
return () => {
wavesurfer.current.un("seek", onSeek);
};
}, [wavesurfer.current, isChromecast]);
useEffect(() => {
const media = Chromecast.media();
if (!media) return;
const remotePlayer = new cast.framework.RemotePlayer();
const remotePlayerController = new cast.framework.RemotePlayerController(remotePlayer);
const onPlayerStateChangeHandler = (event) => {
// console.log("PLAYER STATE CHANGE", event)
switch(event.value) {
case "BUFFERING":
wavesurfer.current.pause();
break
case "PLAYING":
wavesurfer.current.play();
break;
}
};
const onPlayerCurrentTimeChangeHandler = (event) => {
_currentTime = event.value;
setCurrentTime(event.value);
if (event.value > 0) wavesurfer.current.seekTo(event.value / wavesurfer.current.getDuration());
// console.log("time change", event.value, wavesurfer.current.getDuration(), event.value / wavesurfer.current.getDuration())
};
const onMediaChange = (isAlive) => {
if (media.playerState !== chrome.cast.media.PlayerState.IDLE) return;
switch(media.idleReason) {
case chrome.cast.media.IdleReason.FINISHED:
setIsPlaying(false);
setIsChromecast(false);
setVolume($video.current.volume * 100);
$video.current.currentTime = _currentTime;
$video.current.muted = false;
break;
}
};
remotePlayerController.addEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
onPlayerStateChangeHandler,
);
remotePlayerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
onPlayerCurrentTimeChangeHandler,
);
return () => {
remotePlayerController.removeEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
onPlayerStateChangeHandler,
);
remotePlayerController.removeEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
onPlayerCurrentTimeChangeHandler,
);
};
}, [isChromecast, isLoading, render]);
const onPlay = (e) => {
e.preventDefault();
e.stopPropagation();
@ -187,13 +216,20 @@ export function AudioPlayer({ filename, data }) {
};
const onVolumeChange = (v) => {
settings_put("volume", v);
setVolume(v);
if (isChromecast) {
const session = Chromecast.session()
if (session) session.setVolume(v / 100);
} else wavesurfer.current.setVolume(v / 100);
else {
setIsChromecast(false);
notify.send(t("Cannot establish a connection"), "error");
}
} else {
wavesurfer.current.setVolume(v / 100);
settings_put("volume", v);
}
};
const onVolumeClick = () => {
onVolumeChange(0);
};
@ -204,9 +240,52 @@ export function AudioPlayer({ filename, data }) {
String(parseInt(seconds % 60)).padStart(2, "0");
};
const chromecastLoader = () => {
const link = Chromecast.createLink(data);
const media = new chrome.cast.media.MediaInfo(
link,
getMimeType(data),
);
media.metadata = new chrome.cast.media.MusicTrackMediaMetadata()
media.metadata.title = filename.substr(0, filename.lastIndexOf(filepath.extname(filename)));
media.metadata.subtitle = CONFIG.name;
media.metadata.albumName = CONFIG.name;
media.metadata.images = [
new chrome.cast.Image(origin + "/assets/icons/music.png"),
];
setIsChromecast(true);
setIsLoading(false);
setIsPlaying(true);
wavesurfer.current.setMute(true);
wavesurfer.current.pause();
const session = Chromecast.session();
if (!session) return;
setVolume(session.getVolume() * 100);
Chromecast.createRequest(media)
.then((req) => {
req.currentTime = _currentTime;
return session.loadMedia(req)
})
.then(() => setRender(render + 1))
.catch((err) => {
console.error(err);
notify.send(t("Cannot establish a connection"), "error");
setIsChromecast(false);
setIsLoading(false);
});
}
return (
<div className="component_audioplayer">
<MenuBar title={filename} download={data} />
<MenuBar title={filename} download={data}>
{
Chromecast.session() && (
<Icon name="fullscreen" onClick={() => chromecastLoader()} />
)
}
</MenuBar>
<div className="audioplayer_container">
<NgIf cond={error !== null} className="audioplayer_error">
{error}
@ -246,9 +325,9 @@ export function AudioPlayer({ filename, data }) {
<input onChange={(e) => onVolumeChange(Number(e.target.value) || 0)} type="range" min="0" max="100" value={volume}/>
</div>
<div className="timecode">
<span id="currentTime">{ formatTimecode(0) }</span>
<span id="currentTime">{ formatTimecode(currentTime) }</span>
<span id="separator" className="no-select">/</span>
<span id="totalDuration">{ formatTimecode(0) }</span>
<span id="totalDuration">{ formatTimecode(duration) }</span>
</div>
</div>
</div>
@ -257,3 +336,5 @@ export function AudioPlayer({ filename, data }) {
</div>
)
}
let _currentTime = 0; // trick to avoid making too many call to the chromecast SDK

View file

@ -61,12 +61,12 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
const chromecastSetup = (event) => {
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTED:
chromecastHandler();
chromecastLoader();
break;
}
};
const chromecastHandler = (event) => {
const chromecastLoader = () => {
const session = Chromecast.session();
if (!session) return;
@ -96,7 +96,8 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
);
chromecastHandler();
const media = Chromecast.media();
if (media && media.media && media.media.mediaCategory === "IMAGE") chromecastLoader();
return () => {
context.removeEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
@ -119,6 +120,7 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
}
};
const requestFullScreen = () => {
chromecastLoader();
if ("webkitRequestFullscreen" in document.body) {
$container.current.webkitRequestFullscreen();
} else if ("mozRequestFullScreen" in document.body) {
@ -168,10 +170,9 @@ export function ImageViewerComponent({ filename, data, path, subscribe, unsubscr
</div>
</div>
<Pager
type={["image"]}
path={path}
pageChange={(files) => setState({ draggable: files.length > 1 ? true : false })}
next={(e) => setState({ preload: e })} />
/>
</div>
<NgIf cond={state.is_loaded}>

View file

@ -16,8 +16,8 @@ export const MenuBar = (props) => {
<div className="titlebar" style={{ letterSpacing: "0.3px" }}>{props.title}</div>
<div className="action-item no-select">
<span className="specific">
<span id="chromecast-target"></span>
{props.children}
<span id="chromecast-target"></span>
</span>
{
props.download === null ?

View file

@ -74,9 +74,15 @@ class PagerComponent extends React.Component {
if (f === null) return Promise.reject({ code: "NO_DATA" });
return Promise.resolve(f);
})
.then((f) => f.results
.filter((file) => (isImage(file.name) || isVideo(file.name)) &&
file.type === "file"))
.then((f) => f.results.filter((file) => {
if (file.type !== "file") return false;
const mType = getMimeType(file.name);
switch(mType.split("/")[0]) {
case "video": return true;
case "image": return true;
}
return false;
}))
.then((f) => sort(f, settings_get("filespage_sort") || "type"))
.then((f) => findPosition(f, basename(props.path)))
.then((res) => {
@ -98,12 +104,6 @@ class PagerComponent extends React.Component {
}
return [files, i];
};
const isVideo = (filename) => {
return getMimeType(filename).split("/")[0] === "video";
};
const isImage = (filename) => {
return getMimeType(filename).split("/")[0] === "image";
};
}
onFormInputChange(e) {
@ -125,6 +125,8 @@ class PagerComponent extends React.Component {
}
onKeyPress(e) {
e.preventDefault();
e.stopPropagation();
if (e.target.classList.contains("prevent")) return;
if (e.keyCode === 39) {
this.navigatePage(this.calculateNextPageNumber(this.state.n));

View file

@ -33,7 +33,6 @@ export function VideoPlayer({ filename, data, path }) {
setIsLoading(false);
};
const finishHandler = () => {
$video.current.currentTime = 0;
setIsPlaying(false);
};
const errorHandler = (err) => {
@ -94,8 +93,6 @@ export function VideoPlayer({ filename, data, path }) {
case "KeyM": return onVolumeChange(0);
case "ArrowUp": return onVolumeChange(Math.min(volume + 10, 100));
case "ArrowDown": return onVolumeChange(Math.max(volume - 10, 0));
case "ArrowLeft": return onSeek(_currentTime - 5);
case "ArrowRight": return onSeek(_currentTime + 5);
case "KeyL": return onSeek(_currentTime + 10);
case "KeyJ": return onSeek(_currentTime - 10);
case "KeyF": return onRequestFullscreen();
@ -135,13 +132,22 @@ export function VideoPlayer({ filename, data, path }) {
setIsLoading(false);
break;
case cast.framework.SessionState.SESSION_STARTED:
chromecastLoader()
chromecastLoader();
break;
case cast.framework.SessionState.SESSION_ENDING:
setIsChromecast(false);
setVolume($video.current.volume * 100);
$video.current.currentTime = _currentTime;
$video.current.muted = false;
const media = Chromecast.media();
if (media && media.playerState === "PLAYING") $video.current.play();
else if (media && media.playerState === "PAUSED") $video.current.pause();
break;
case cast.framework.SessionState.SESSION_ENDED:
setIsChromecast(false);
setVolume($video.current.volume * 100);
$video.current.currentTime = _currentTime;
$video.current.muted = false;
if (isPlaying) $video.current.play();
break;
}
};
@ -150,8 +156,6 @@ export function VideoPlayer({ filename, data, path }) {
chromecastSetup,
);
return () => {
const media = Chromecast.media();
if (media) media.removeUpdateListener(chromecastMediaHandler);
context.removeEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
chromecastSetup,
@ -160,20 +164,71 @@ export function VideoPlayer({ filename, data, path }) {
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (isLoading) return;
if (isChromecast) {
const media = Chromecast.media();
if (!media) return;
_currentTime = media.getEstimatedTime();
setIsBuffering(media.playerState === "BUFFERING");
} else _currentTime = $video.current.currentTime;
setCurrentTime(_currentTime)
}, 100);
return () => {
clearInterval(interval);
if (isLoading === true) return;
else if (isChromecast === false) {
const interval = setInterval(() => {
_currentTime = $video.current.currentTime;
setCurrentTime(_currentTime);
}, 100);
return () => {
clearInterval(interval);
};
}
const media = Chromecast.media();
if (!media) return;
const remotePlayer = new cast.framework.RemotePlayer();
const remotePlayerController = new cast.framework.RemotePlayerController(remotePlayer);
const onPlayerStateChangeHandler = (event) => {
switch(event.value) {
case "BUFFERING":
setIsBuffering(true);
break
case "PLAYING":
setIsBuffering(false);
break;
}
};
}, [data, isChromecast, isLoading]);
const onPlayerCurrentTimeChangeHandler = (event) => {
_currentTime = event.value;
setCurrentTime(event.value);
};
const onMediaChange = (isAlive) => {
if (media.playerState !== chrome.cast.media.PlayerState.IDLE) return;
switch(media.idleReason) {
case chrome.cast.media.IdleReason.FINISHED:
setIsPlaying(false);
setIsChromecast(false);
setVolume($video.current.volume * 100);
$video.current.currentTime = _currentTime;
$video.current.muted = false;
break;
}
};
media.addUpdateListener(onMediaChange);
remotePlayerController.addEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
onPlayerStateChangeHandler,
);
remotePlayerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
onPlayerCurrentTimeChangeHandler,
);
return () => {
media.removeUpdateListener(onMediaChange);
remotePlayerController.removeEventListener(
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED,
onPlayerStateChangeHandler,
);
remotePlayerController.removeEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
onPlayerCurrentTimeChangeHandler,
);
};
}, [isChromecast, isLoading, render]);
const onPlay = () => {
setIsPlaying(true);
@ -194,11 +249,10 @@ export function VideoPlayer({ filename, data, path }) {
if (isChromecast) {
const media = Chromecast.media();
if (!media) return;
setIsLoading(true);
setIsBuffering(true);
const seekRequest = new chrome.cast.media.SeekRequest();
seekRequest.currentTime = parseInt(newTime);
media.seek(seekRequest);
setTimeout(() => setIsLoading(false), 1000);
} else $video.current.currentTime = newTime;
};
const onClickSeek = (e) => {
@ -213,18 +267,24 @@ export function VideoPlayer({ filename, data, path }) {
onPause();
n = 0;
}
onSeek(n * duration);
_currentTime = n * duration;
setCurrentTime(_currentTime);
onSeek(_currentTime);
};
const onVolumeChange = (n) => {
settings_put("volume", n);
setVolume(n);
if (isChromecast) {
const session = Chromecast.session()
if (session) session.setVolume(n / 100);
else notify.send(t("Cannot establish a connection"), "error");
else {
setIsChromecast(false);
notify.send(t("Cannot establish a connection"), "error");
}
} else {
$video.current.volume = n / 100;
settings_put("volume", n);
}
else $video.current.volume = n / 100;
};
const onProgressHover = (e) => {
@ -232,9 +292,9 @@ export function VideoPlayer({ filename, data, path }) {
const width = e.clientX - rec.x;
const time = duration * width / rec.width;
let posX = width;
posX = Math.max(posX, 30) // min boundary
posX = Math.max(posX, 30);
posX = Math.min(posX, e.target.clientWidth - 30);
setHint({ x: `${posX}px`, time })
setHint({ x: `${posX}px`, time });
};
const onRequestFullscreen = () => {
@ -242,9 +302,7 @@ export function VideoPlayer({ filename, data, path }) {
if (!session) {
document.querySelector(".video_screen").requestFullscreen();
requestAnimationFrame(() => setRender(render + 1));
} else {
chromecastLoader();
}
} else chromecastLoader();
};
const isFullscreen = () => {
@ -290,47 +348,34 @@ export function VideoPlayer({ filename, data, path }) {
];
setIsChromecast(true);
setIsPlaying(true);
setIsLoading(false);
setIsPlaying(true);
setIsBuffering(false);
$video.current.muted = true;
$video.current.pause();
const session = Chromecast.session();
if (!session) return;
$video.current.pause();
setVolume(session.getVolume() * 100);
Chromecast.createRequest(media)
return Chromecast.createRequest(media)
.then((req) => {
req.currentTime = parseInt($video.current.currentTime);
setCurrentTime($video.current.currentTime);
req.currentTime = parseInt(_currentTime);
return session.loadMedia(req);
})
.then(() => {
const media = session.getMediaSession();
if (!media) return;
media.addUpdateListener(chromecastMediaHandler);
})
.then(() => setRender(render + 1))
.catch((err) => {
console.error(err);
notify.send(t("Cannot establish a connection"), "error");
setIsChromecast(false);
setIsLoading(false);
});
};
const chromecastMediaHandler = (isAlive) => {
if (isAlive) return;
const session = Chromecast.session();
if (session) {
session.endSession();
$video.current.muted = false;
setVolume($video.current.volume * 100);
setIsChromecast(false);
onSeek(0);
onPause();
}
};
return (
<div className="component_videoplayer">
<MenuBar title={filename} download={data} />
<div className="component_videoplayer" >
<MenuBar title={filename} download={data}>
<Icon name="fullscreen" onClick={onRequestFullscreen} />
</MenuBar>
<div className="video_container" ref={$container}>
<ReactCSSTransitionGroup
transitionName="video"
@ -339,7 +384,11 @@ export function VideoPlayer({ filename, data, path }) {
transitionEnter={true}
transitionEnterTimeout={300}
transitionAppearTimeout={300}>
<div className={"video_screen" + (isPlaying ? " video-state-play" : " video-state-pause")}>
<div className={
"video_screen" +
(isBuffering ? " video-state-buffer" : isPlaying ? " video-state-play" : " video-state-pause") +
(isChromecast ? " is-casting-yes" : " is-casting-no")
}>
<div className="video_wrapper" style={isFullscreen() ? {
maxHeight: "inherit",
height: "inherit",
@ -392,13 +441,6 @@ export function VideoPlayer({ filename, data, path }) {
)
}
</span>
<div className="pull-right">
{
<React.Fragment>
<Icon name="fullscreen" onClick={onRequestFullscreen} />
</React.Fragment>
}
</div>
</div>
)
}

View file

@ -38,7 +38,9 @@
width: 100%;
}
&.video-state-pause .videoplayer_control { opacity: 1; transition: 0.1s opacity ease; }
&.is-casting-yes .videoplayer_control,
&.video-state-pause .videoplayer_control,
&.video-state-buffer .videoplayer_control,
&.video-state-play:hover .videoplayer_control { opacity: 1; transition: 0.1s opacity ease; }
.videoplayer_control {
transition: 0.5s opacity ease;
@ -142,10 +144,6 @@
width: 100%;
}
}
.pull-right {
margin-left: auto;
padding-right: 10px;
}
}
}
video {