import React, { Component } from "react" import HeaderBar from "src/common/HeaderBar" import "./video.scss" import { NavLink, Route, Redirect, Switch } from "react-router-dom" import { http, getParam, browser } from "src/utils" import Recommendation from "./recommendation" import VideoCatalog from "./video-catalog" import DatumCatalog from "./datum-catalog" import { Toast } from "antd-mobile" import videojs from "video.js" import "video.js/dist/video-js.min.css" import { Modal } from "antd-mobile" import { Loading } from "src/common" import { connect } from "react-redux" import jsCookie from "js-cookie" import Single from "src/components/detail/single" import SingleSuccess from "../detail/single/singleSuccess" import "./CustomPlayButton" let alert = Modal.alert function ProgressShareModal(props) { return ( props.isShow && ( <div className="progress-share-modal-wrapper"> <div className="progress-share-modal"> <div className="title">每日打卡</div> <ul className="progress-container"> <li> <div className="title">累计学习</div> <div className="number"> <span className="num">{props.data.learn_day_count}</span>天 </div> </li> <li> <div className="title">行动力超过</div> <div className="number"> <span className="num"> {parseFloat(props.data.action_power)} </span> % </div> </li> </ul> <div className="share-container"> <div className="title">分享到</div> <ul> <li className="share-icon"> <a style={{ display: "block" }} href={props.data.url}> <div className="icon"> <i className="iconfont iconweixinzhifu" /> </div> <div className="text">微信好友</div> </a> </li> <li className="share-icon"> <a style={{ display: "block" }} href={props.data.url}> <div className="icon"> <i className="iconfont iconpengyouquaniconx" /> </div> <div className="text">朋友圈</div> </a> </li> </ul> </div> <i className="iconfont iconiconfront-2 close" onClick={props.closeShareModal} /> </div> </div> ) ) } class Video extends Component { video //video element player //video player instance currentVideoTimer currentVideoExpireTime courseID ws //websocket instance timer token count watchSec previousPlaybackRate = 1 currentPlaybackRate = 1 reconnect = true recordSocket recordTimer isCurrentVideoFirstPlay = true RECENTLEARN = "recent_learn" state = { title: "", courseId: null, videoList: [], datum: [], activeIndex: 0, isAuth: true, course: {}, // course.course_id 为 0 或 '' 时 为免费课程 salePrice: null, vCourseId: null, isLoading: true, isShowShareModal: false, shareData: {}, singleBox: false, singMess: "", singleType: 1, // 单集购买需要 nowPrice: 0, // 单集购买需要 laterPrice: 0, // 单集购买需要 limitFreeNoPromptChecked: false, //是否勾选"不再显示此弹框"选项 showLimitFreePopup: false, limitFreePopup: {}, isShowNeverShowPopupOption: false, //限时免费课程 播放结束后是否显示"不再显示此弹框"选项 limitFreePopupVideos: JSON.parse( localStorage.getItem("limit-free-popup-videos") ), currentVideoExpireTime: "", } componentDidMount() { if (window.location.protocol === "https:") { window.location.replace("http" + window.location.href.slice(5)) return } this.courseID = getParam("id") if (!this.courseID) { this.props.history.replace("/") return } this.setState({ courseId: this.courseID, }) const { location: { state = {} }, } = this.props if (state.oid) { this.check(state.oid) } if (getParam("is_class") === 1 || getParam("weixinpay")) { this.payCallback() } if (browser.isWeixin) { this.isweixinPay() } this.token = jsCookie.get("token") this.getVideoList() this.getDatumCatalog() } // 直接购买 tobuy = () => { // 详情页单集购买到该页面,url中的id不是课程id const { course = {} } = this.state http .get(`${API["base-api"]}/m/cart/addtopreorder/[${course.course_id}]`) .then((res) => { if (res.data.errno === 0) { this.props.history.push(`/order?id=${course.course_id}`, { simple: 1, }) } else { Toast.info(res.data.msg, 2) } }) } // 购买单集 toSingleset = (item) => { this.setState({ singleBox: true, singleType: 1, singMess: item, }) window.localStorage.setItem("singMess", JSON.stringify(item)) } // 自组件传给父组件的boxHide boxHide = (val) => { this.setState({ singleBox: val, singleType: 1 }) } // 单集购买 H5支付成功后回调 payCallback = () => { const _this = this if (!getParam("oid")) { return } else { this.setState({ singMess: JSON.parse(window.localStorage.getItem("singMess")), }) _this.intervalPayStatus = setInterval(function () { http .get(`${API["base-api"]}/m/orderState/oid/${getParam("oid")}`) .then((res) => { if (res.data.errno === 401) { clearInterval(_this.intervalPayStatus) _this.intervalPayStatus = null // 获取课程类型 http .get(`${API["base-api"]}/class_order_status/${getParam("oid")}`) .then((res) => { if (Number(res.data.data.errno) === 200) { // 正常购买单集成功 _this.setState({ singleType: 6, }) } else if (Number(res.data.data.errno) === 201) { // 0元参团 _this.setState({ singleType: 4, }) } else if (Number(res.data.data.errno) === 202) { // 0元购 _this.setState({ singleType: 3, }) } else if (Number(res.data.data.errno) === 203) { // 三天内特价 _this.setState({ nowPrice: res.data.data.data.now_price, laterPrice: res.data.data.data.three_day_later_price, singleType: 2, }) } else { Toast.info(res.data.data.msg, 2) } }) } }) }, 1000) } } // 单集购买 微信内支付成功后回调 isweixinPay = () => { let _this = this let weixin_code = getParam("code") if (weixin_code) { if (!getParam("oid")) { return } else { this.setState({ singMess: JSON.parse(window.localStorage.getItem("singMess")), }) // this.props.weixinPay(weixin_code) http .get( `${API["base-api"]}/pay/wxpay/pub_charge/oid/${getParam( "oid" )}/code/${weixin_code}` ) .then((res) => { if (res.data.errno === 0) { const data = res.data.data function onBridgeReady() { WeixinJSBridge.invoke( "getBrandWCPayRequest", { appId: data.appId, //公众号名称,由商户传入 timeStamp: data.timeStamp, //时间戳,自1970年以来的秒数 nonceStr: data.nonceStr, //随机串 package: data.package, signType: data.signType, //微信签名方式: paySign: data.paySign, //微信签名 }, function (res) { if (res.err_msg === "get_brand_wcpay_request:ok") { Toast.info("支付成功", 2) _this.intervalPayStatus = setInterval(function () { http .get( `${API["base-api"]}/m/orderState/oid/${getParam( "oid" )}` ) .then((res) => { if (res.data.errno === 401) { clearInterval(_this.intervalPayStatus) _this.intervalPayStatus = null // 获取课程类型 http .get( `${ API["base-api"] }/class_order_status/${getParam("oid")}` ) .then((res) => { if (Number(res.data.data.errno) === 200) { // 正常购买单集成功 _this.setState({ singleType: 6, }) } else if ( Number(res.data.data.errno) === 201 ) { // 0元参团 _this.setState({ singleType: 4, }) } else if ( Number(res.data.data.errno) === 202 ) { // 0元购 _this.setState({ singleType: 3, }) } else if ( Number(res.data.data.errno) === 203 ) { // 三天内特价 _this.setState({ nowPrice: res.data.data.data.now_price, laterPrice: res.data.data.data .three_day_later_price, singleType: 2, }) } else { Toast.info(res.data.data.msg, 2) } }) } }) }, 1000) } else { alert("支付失败") } } ) } if (typeof WeixinJSBridge == "undefined") { if (document.addEventListener) { document.addEventListener( "WeixinJSBridgeReady", onBridgeReady, false ) } else if (document.attachEvent) { document.attachEvent("WeixinJSBridgeReady", onBridgeReady) document.attachEvent("onWeixinJSBridgeReady", onBridgeReady) } } else { onBridgeReady() } } else { Toast.info(res.data.msg, 2) } }) } } } // 判断支付是否成功 check = (oid) => { this.setState({ singMess: JSON.parse(window.localStorage.getItem("singMess")), }) http.get(`${API["base-api"]}/class_order_status/${oid}`).then((res) => { if (Number(res.data.data.errno) === 200) { // 正常购买单集成功 this.setState({ singleType: 6, }) } else if (Number(res.data.data.errno) === 201) { // 0元参团 this.setState({ singleType: 4, }) } else if (Number(res.data.data.errno) === 202) { // 0元购 this.setState({ singleType: 3, }) } else if (Number(res.data.data.errno) === 203) { // 三天内特价 this.setState({ nowPrice: res.data.data.data.now_price, laterPrice: res.data.data.data.three_day_later_price, singleType: 2, }) } else { Toast.info(res.data.data.msg, 2) } }) } // 9502 初始化 监听事件 setupWS = () => { this.ws = new WebSocket(API["process-api"]) this.ws.addEventListener("error", () => { this.ws = null }) this.ws.addEventListener("close", () => { if (this.reconnect) { this.ws = null setTimeout(() => { this.setupWS() }, 1000) } clearInterval(this.timer) this.timer = null }) this.ws.addEventListener("message", (e) => { const data = JSON.parse(e.data) data.code === 4040 && (this.reconnect = false) if (data.code === 0 && data.data && data.data.position) { this.player.currentTime(data.data.position) } }) } sendMessage = (message) => { let readyState = this.ws.readyState, _this = this if (readyState === 1) { this.ws && this.ws.send(JSON.stringify(message)) } else if (readyState === 3) { this.ws.close() this.ws = null let reconnect = setTimeout(function () { clearTimeout(reconnect) reconnect = null _this.ws = new WebSocket(API["process-api"]) }, 500) } } //视频结束请求接口 getShareProgressInfo = () => { http .get( `${API["base-api"]}/m/aist/share_data/${this.courseID}/${ this.state.videoList[this.state.activeIndex]["id"] }` ) .then((res) => { const { data } = res if (data.errno === 200) { this.setState({ shareData: data.data, isShowShareModal: true }) } }) } //告诉服务端计算进度 countSchedule = () => { const { videoList, activeIndex, vCourseId, course = {} } = this.state if (Number(course.course_id) === 0 || course.course_id === "") { return } let ctype = 0 if (course.is_aist) { ctype = 2 } // 计算进度 根据ctype判断 课程类型 0-视频 1-直播 2-AI特训营 this.sendMessage({ mtype: "count_schedule", uid: this.props.user.data.uid, token: this.token, platform: 5, video_id: videoList[activeIndex]["id"], course_id: this.state.courseId, v_course_id: vCourseId, ctype: ctype, }) } // 发送时间消息 sendWatchTime = (sec, rate) => { const { videoList, activeIndex, vCourseId, course = {} } = this.state // 免费课程不发送 // if (Number(course.course_id) === 0 || course.course_id === '') { // return; // } // 时间为0 不发送消息 if (Number(sec) === 0) { return } let ctype = 0 if (course.is_aist) { ctype = 2 } // 时间足够不发送 // if(this.timeEnough) { // return; // } this.sendMessage({ mtype: "watch_time", rate, time: sec, video_id: videoList[activeIndex]["id"], course_id: this.state.courseId, v_course_id: vCourseId, uid: this.props.user.data.uid, token: this.token, platform: 5, position: parseInt(this.player.currentTime()), ctype: ctype, }) } setupTimer = () => { this.count = 0 this.watchSec = 0 clearInterval(this.timer) this.timer = null this.timer = setInterval(() => { if (this.player && this.player.player()) { if (this.count === 5) { this.sendWatchTime(this.watchSec, this.currentPlaybackRate) this.count = this.watchSec = 0 } else { !this.player.paused() && this.watchSec++ !this.player.paused() && this.count++ } } }, 1000) } // 初始化视频播放器 initializePlayer = () => { window.HELP_IMPROVE_VIDEOJS = false this.player = videojs(this.video, { controls: true, preload: "auto", bigPlayButton: false, textTrackDisplay: false, posterImage: false, errorDisplay: false, playbackRates: ["0.75", "1", "1.5", "2"], controlBar: { pictureInPictureToggle: false, }, }) this.player.addChild("CustomPlayButtonCover") this.player.on("play", () => { const { videoList, activeIndex, vCourseId } = this.state // 当视频播放时 看是否是第一次播放(初次进入页面 刷新页面 切换视频 都是第一次播放 需要获取上次的播放时间) if (this.isCurrentVideoFirstPlay) { // 当某些原因导致视频暂停时(用户暂停 网络不好等) 再播放时不需要发送 this.isCurrentVideoFirstPlay = false // 发送消息 recent_learn this.ws.send( JSON.stringify({ mtype: this.RECENTLEARN, uid: this.props.user.data.uid, token: this.token, platform: 5, video_id: videoList[activeIndex]["id"], course_id: this.state.courseId, v_course_id: vCourseId, is_live: 0, }) ) } if (!this.timer) { this.setupTimer() } }) this.player.on("durationchange", () => { let videoTime = window.sessionStorage.getItem("videoTimeBeforeReload") let rate = window.sessionStorage.getItem("videoRateBeforeReload") if (videoTime) { this.player.currentTime(Number(videoTime)) rate && this.player.playbackRate(rate) window.sessionStorage.removeItem("videoTimeBeforeReload") window.sessionStorage.removeItem("videoRateBeforeReload") } }) this.player.on("ratechange", () => { this.currentPlaybackRate = this.player.playbackRate() this.sendWatchTime(this.watchSec, this.previousPlaybackRate) this.count = this.watchSec = 0 this.previousPlaybackRate = this.currentPlaybackRate }) this.player.on("ended", () => { this.sendWatchTime(this.watchSec, this.currentPlaybackRate) this.count = this.watchSec = 0 this.countSchedule() // 计算进度 -- 播放完毕 // 返现课程才出现打卡记录 if (this.state.course.is_aist) { this.getShareProgressInfo() } clearInterval(this.timer) this.timer = null if (this.state.limitFreePopup.is_free) { this.setState({ showLimitFreePopup: true, }) } }) this.player.on("error", () => { this.handleVideoAuthError(0) }) this.player.on("waiting", () => { this.handleVideoAuthError(3000) }) } sendLastRecord = () => { http.post(`${API.home}/m/course/record_last_video`, { v_course_id: this.state.course["v_course_id"], video_id: this.state.videoList[this.state.activeIndex].id, }) } componentWillUnmount() { this.player && this.player.dispose() clearInterval(this.timer) this.timer = null this.ws && this.ws.close() this.ws = null clearInterval(this.recordTimer) this.recordSocket && this.recordSocket.close() this.recordSocket = null } //处理视频播放权限问题 handleVideoAuthError = (interval) => { clearTimeout(this.currentVideoTimer) this.currentVideoTimer = setTimeout(() => { if (this.currentVideoExpireTime < new Date().getTime()) { window.sessionStorage.setItem( "videoTimeBeforeReload", this.player.currentTime() ) window.sessionStorage.setItem( "videoRateBeforeReload", this.player.playbackRate() ) this.getVideoSrc() } }, interval) } //请求签名视频地址 getVideoSrc = () => { const { videoList, activeIndex } = this.state let lesson = videoList[activeIndex] http .get(`${API.home}/web/check_video/${lesson.id}/${lesson.v_course_id}`) .then((res) => { const { data, code, msg } = res.data let url = new URL(data.url).searchParams if (code === 200) { this.currentVideoExpireTime = url.has("Expires") && Number(url.get("Expires")) * 1000 this.setPlayerSrc(data.url) } else { Toast.info(msg) } }) } // 选择新的视频 selectVideo = (index) => { if (index === this.state.activeIndex) { return } this.isCurrentVideoFirstPlay = true // 切换视频则重置这个变量 因为新视频肯定是首次播放 this.sendWatchTime(this.watchSec, this.currentPlaybackRate) this.countSchedule() // 计算进度 -- 选择新视频(可能是M端特有的) this.setupTimer() this.setState( { activeIndex: index, }, () => { if (this.hasAuth(this.state.activeIndex)) { this.getVideoSrc() this.sendLastRecord() this.playVideo() } else { this.getCoursePrice() } } ) } getLastVideoIndex = (lastIndex) => { return this.state.videoList.findIndex( (item) => Number(item.id) === Number(lastIndex) ) } getVideoList = () => { let url = "" if (getParam("video_id")) { url = `${API.home}/m/course/play/${ this.courseID + "?video_id=" + getParam("video_id") }` http.post(`${API["base-api"]}/sys/get_class_audition`, { video_id: getParam("video_id"), }) } else { url = `${API.home}/m/course/play/${this.courseID}` } http.get(url).then((res) => { const { data = {}, code } = res.data if (code === 200) { this.setState( (state) => ({ videoList: data["lessons"], course: data.course, courseId: data.course["course_id"], vCourseId: data.course["v_course_id"], title: data.course["course_title"], isLoading: false, }), this.playSetup ) data.course.course_id && this.getLimitFreePopup(data.course.course_id) } else { Toast.info(data.msg) } }) } playSetup = () => { // is_aist,是否AI特训营 const { course = {} } = this.state let _this = this this.setupWS() this.setupTimer() let scheduleTime = setTimeout(function () { clearTimeout(scheduleTime) scheduleTime = null _this.countSchedule() // 刚进入页面的时候 就计算进度 先获取视频列表getVideoList 获取列表后 播放选择的视频 然后计算进度 }, 1000) let index = this.getLastVideoIndex(course.last_video_id) index = index >= 0 ? index : 0 this.setState( { activeIndex: index, }, () => { if (this.lessonAvailable(index)) { if (this.hasAuth(index)) { Promise.resolve().then(() => { this.initializePlayer() this.playWithAuth() }) } else { this.getCoursePrice() } } else { alert("暂无视频", "", [ { text: "OK", onPress: () => { this.props.history.push("/") }, }, ]) } } ) } setPlayerSrc = (src) => { if (!this.player) { this.initializePlayer() } this.player.src({ src, type: "application/x-mpegURL", }) this.player.play() } playVideo = () => { this.player.ready(() => { this.player.play() }) } getDatumCatalog() { http.get(`${API.home}/m/course/data/${this.courseID}`).then((res) => { const data = res.data if (data.code === 200) { this.setState({ datum: data.data, }) } else { Toast.info(data.msg) } }) } lessonAvailable = (index) => { return this.state.videoList[index]["video_size"] !== 0 } getCoursePrice = () => { const { course = {} } = this.state http.get(`${API.home}/sys/course/price/${course.course_id}`).then((res) => { const { data } = res if (data.code === 200) { this.setState({ salePrice: data.data["sale_price"], }) } }) } playWithAuth = () => { const { activeIndex } = this.state if (this.hasAuth(activeIndex)) { this.getVideoSrc() } } hasAuth = (index) => { const { videoList } = this.state let lesson = videoList[index] if (lesson["video_auth"]) { this.setState({ isAuth: true, }) return true } else { this.setState({ isAuth: false, }) return false } } getLimitFreePopup = (id) => { http .post(`${API.home}/sys/popup`, { course_id: id, }) .then((res) => { const { code, msg, data } = res.data if (code === 200) { const { courseId, limitFreePopupVideos } = this.state this.setState({ limitFreePopup: data, isShowNeverShowPopupOption: limitFreePopupVideos ? limitFreePopupVideos.includes(courseId) : false, }) } else { Toast.info(msg, 2, null, false) } }) } checkNeverShowLimitFreePopup = () => { if (!this.state.limitFreeNoPromptChecked) { return } http .post(`${API.home}/sys/checklist`, { course_id: this.state.course.course_id, }) .then((res) => { const { code, msg } = res.data if (code === 200) { this.setState({ limitFreePopup: { ...this.state.limitFreePopup, is_free: 0 }, }) } else { Toast.info(msg, 2, null, false) } }) } render() { let { match, location, history } = this.props const { videoList, activeIndex, isAuth, salePrice, course, singleBox, singleType, showLimitFreePopup, limitFreePopup, isShowNeverShowPopupOption, } = this.state let toHref = "" if (location.state && location.state.to && location.state.to === "detail") { toHref = `/detail?id=${course.course_id}` } return ( <div className="play"> <HeaderBar title={this.state.title} arrow={true} toHref={() => { toHref ? history.push(toHref, { to: "classify", }) : history.go(-1) }} /> <Loading isLoading={this.state.isLoading}> <div className="video"> <video className={"video-js"} ref={(el) => (this.video = el)} webkit-playsinline="true" playsInline={true} x-webkit-airplay="allow" x5-video-player-type="h5" > <source src={"/"} type="application/x-mpegURL" /> </video> {!isAuth && !!videoList[activeIndex]["is_class"] && ( <div className="purchase-box"> <div className="hint">您尚未购买该课时,请购买后学习。</div> <div className="btns"> <button type="button" onClick={this.tobuy} className="purchase-class" > ¥{salePrice} 购买课程 </button> <button type="button" onClick={this.toSingleset.bind( this, videoList[activeIndex] )} className="purchase-episode" > ¥{videoList.length && videoList[activeIndex]["class_price"]}{" "} 购买单集 </button> </div> </div> )} {!isAuth && !!course.is_aist && ( <div className="is-aist-box"> <i className={"iconfont iconiconfront-21"}></i> <p className={"time"}> {videoList[activeIndex]["aist_start_time"]} </p> <p className={"time"}>请耐心等待...</p> </div> )} </div> <div className="tab"> <div> <NavLink to={{ pathname: `${match.url}/video`, search: `?id=${this.courseID}`, }} replace activeClassName="active" > 视频 </NavLink> </div> <div> <NavLink to={{ pathname: `${match.url}/datum`, search: `?id=${this.courseID}`, }} replace activeClassName="active" > 资料 </NavLink> </div> </div> {/*单集购买*/} {singleBox && ( <Single courseId={course.course_id} singleBox={this.state.singleBox} boxHide={this.boxHide} data={this.state.singMess} singleType={this.state.singleType} vcourseId={course.v_course_id} videoId={this.state.singMess.video_id} check={this.check} title={this.state.singMess.course_tile} /> )} {/* 单集购买成功 */} {singleType !== 1 && ( <SingleSuccess courseId={course.course_id} boxHide={this.boxHide} data={this.state.singMess} singleType={singleType} vcourseId={course.v_course_id} videoId={this.state.singMess.video_id} nowPrice={this.state.nowPrice} laterPrice={this.state.laterPrice} /> )} </Loading> <Switch> <Redirect exact from={"/play"} to={{ pathname: "/play/video", search: location.search, }} /> <Route path={`${match.path}/video`} render={(props) => { return ( <VideoCatalog activeIndex={this.state.activeIndex} selectVideo={this.selectVideo} videoCatalog={videoList} isAist={course.is_aist} {...props} /> ) }} /> <Route path={`${match.path}/datum`} render={(props) => { return <DatumCatalog {...props} datum={this.state.datum} /> }} /> </Switch> <Route render={(props) => { return this.state.vCourseId ? ( <Recommendation {...props} vCourseId={this.state.vCourseId} /> ) : null }} /> <ProgressShareModal isShow={this.state.isShowShareModal} closeShareModal={() => this.setState({ isShowShareModal: false })} data={this.state.shareData} /> {showLimitFreePopup && ( <div className={"limit-free-cover"}> <div className="free-popup"> <div className="title"> <span>{limitFreePopup.pop_descbition}</span> </div> <div className={"des"}> <img className="qrcode" src={limitFreePopup.wechat_img} alt="" /> <span>长按/扫码识别</span> <span> 添加时请备注<span>{course.course_id}</span>哦 </span> <div className="no-prompt"> {isShowNeverShowPopupOption && ( <label htmlFor="no-prompt"> <span className={`checkbox-label ${ this.state.limitFreeNoPromptChecked ? "checked" : "unchecked" }`} > <i className={"iconfont iconiconfront-73"} /> </span> <input type="checkbox" id={"no-prompt"} onChange={(e) => { this.setState({ limitFreeNoPromptChecked: e.target.checked, }) }} /> <span>本课程不再提示</span> </label> )} </div> </div> <i className={"close-btn iconfont iconiconfront-2"} onClick={() => { this.setState({ showLimitFreePopup: false, isShowNeverShowPopupOption: true, }) const { courseId, limitFreePopupVideos } = this.state localStorage.setItem( "limit-free-popup-videos", JSON.stringify( limitFreePopupVideos ? [...limitFreePopupVideos, courseId] : [courseId] ) ) this.checkNeverShowLimitFreePopup() }} /> </div> </div> )} </div> ) } } export default connect((state) => ({ user: state.user }), null)(Video)