y595705120 2 тижнів тому
батько
коміт
50df7a282a

+ 10 - 82
src/containers/center/play/index.vue

@@ -75,34 +75,9 @@
         <div v-if="show ==3" class="lwh-ul-form mt20">
           <exam-list :courseId="courseId"> </exam-list>
         </div>
-
-        <el-dialog class="previewDialog" :visible.sync="testExamDalog"
-          top="50px" width="1024px"
-          @close="closeTestModel()">
-          <ICourseAnswerTest :info="info" :extraXs="extraXs" @updateExtraXs="updateExtraXs" :counter="counter" :groupId="tpl.testGroupId"> </ICourseAnswerTest>
-        </el-dialog>
       </div>
      </div>
-    <el-dialog
-     class="media-dialog"
-     :close-on-click-modal="false"
-     :visible.sync="mediaDialog"
-     top="50px"
-     :title="media.name"
-     :width="mediaType=='hls'?'720px':'540px'"
-     >
-        <Media
-          :options="options"
-          :dialog="mediaDialog"
-          :media="media"
-          :duration="info.duration"
-          @close="closeMedia"
-          :mediaType="mediaType"
-          @changeMedia="changeMedia"
-          @updateOption = "updateOption"
-          @update="update">
-        </Media>
-    </el-dialog>
+
 
     <el-dialog title="输入认证" center :visible.sync="editRzcode"  >
       <el-form  label-width="120px" ref="elAdd">
@@ -179,26 +154,18 @@
     },
     components:{Media,ExamList,ICourseInfo,ICourseInfoTest,ICourseAnswerTest,ICourseInfoXsExtra},
     beforeMount() {
+      console.log('beforeMount')
       this.courseId = +this.$route.params.courseId
       this.getData()
-    },
-    watch:{
-      show(val){
-        this.updateShowList()
+      this._onVisible = () => {
+        if (document.visibilityState === 'visible') {
+          this.getData()
+        }
       }
-      // mediaType(val){
-      //   if( !val ) return;
-      //   let mediaUrl = this.mediaUrl;
-      //   if( val == 'ld'){
-      //     mediaUrl = mediaUrl.replace('/hls/', '/ld/');
-      //   }else{
-      //     mediaUrl = mediaUrl.replace('/ld/', '/hls/');
-      //   }
-      //   this.options.sources = [{src:mediaUrl,type: "application/x-mpegURL"}];
-      //   this.options.playtimes = this.media.position||0;
-      //   this.options.autoplay = this.options.playtimes>0;
-      //   this.mediaDialog = true;
-      // }
+      document.addEventListener('visibilitychange', this._onVisible)
+    },
+    beforeDestroy() {
+      document.removeEventListener('visibilitychange', this._onVisible)
     },
     filters:{
       showProgressColor: function (val) {
@@ -312,10 +279,6 @@
       formatString(val){
         return ()=> val;
       },
-      closeTestModel(){
-        this.getData()
-        this.testExamDalog = false;
-      },
       getData() {
         let param = { courseId: this.courseId }
         httpServer("course.getCourse", param).then(res => {
@@ -370,47 +333,12 @@
       updateExtraXs( param ){
         this.extraXs = Object.assign( this.extraXs, param);
       },
-      //
-      update( item ){
-        if( item.position> this.media.position) {
-          this.media.position = item.position
-        }
-        this.media.isFinish = item.isFinish
-        this.media.id = item.id
-        this.media.percent = getPercent(this.media);
-        this.updateShowList()
-      },
-      closeMedia(){
-        this.mediaType = '';
-        this.mediaDialog=false;
-      },
       // 加载媒体
       loadMedia( item, index ) {
           let courseId = this.courseId
           let mediaId = item.mediaId;
-          // this.$router.push({name:'playMedia', params:{courseId}, query:{name}})
           const {href} = this.$router.resolve({name:'playMedia', params:{courseId}, query:{mediaId}})
-          console.log(href)
           window.open(href, '_blank');
-        // this.media = item;
-        // this.media.index = index
-        // httpServer('course.GetMedia', {id:item.id}).then( res => {
-        //   if( res.code != 200) return;
-        //   let {mediaUrl, id, position, marks} = res.data||{};
-        //   this.mediaUrl = res.data.mediaUrl;
-        //   if(  this.mediaUrl.indexOf('/hls/') == -1){
-        //     this.mediaType = 'ld'
-        //   }else{
-        //     this.mediaType = 'hls'
-        //   }
-        //   this.options.marks = !!marks;
-        //   this.media.position = position;
-        //   this.media.id = id;
-        //   this.options.sources = [{src:this.mediaUrl,type: "application/x-mpegURL"}];
-        //   this.options.playtimes = position||1;
-        //   this.options.autoplay  = position>0;
-        //   this.mediaDialog = true;
-        // });
       }
     }
   };

+ 9 - 0
src/containers/center/play/play.css

@@ -0,0 +1,9 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}

+ 846 - 0
src/containers/center/play/play.vue

@@ -0,0 +1,846 @@
+<template>
+  <div class="pc-player-container">
+    <div class="pc-player-wrapper">
+      <div class="pc-player-left">
+        <div class="video-section">
+          <div class="video-title">{{ media.name || tpl.name }}</div>
+          <div class="video-player-wrap">
+            <video-player id="myVideo1" ref="videoPlayer" :playsinline="true" @pause="onPlayerPause($event)"
+              @timeupdate="onPlayerTimeupdate($event)" @play="onPlayerStart($event)" @ready="playerReadied"
+              @ended="onPlayerEnded($event)" @error="onPlayerError($event)"
+              :globalOptions="{controls:true}" :options="options">
+            </video-player>
+          </div>
+          <div class="dialog-footer pt30">
+            <el-row class="media-footer">
+              <el-col :span="6" class="media-time">
+                <span>{{curTimes|useTime}}</span>
+                <strong>/</strong>
+                <span>{{media.duration|useTime}}</span>
+              </el-col>
+              <el-col :span="12" class="media-center">
+                <span class="volume-control">
+                  <span class="vol-icon" @click="toggleMute">音量</span>
+                  <el-slider class="volume-slider" :value="volume" :min="0" :max="1" :step="0.05"
+                    @input="setVolume"></el-slider>
+                </span>
+              </el-col>
+              <el-col :span="6" class="media-select">
+                <div style="margin-top:0px;float: right;">
+                  <el-select placeholder="流畅" :value="mediaType" class="media-el-select"  @change="changeMedia">
+                    <el-option label="流畅" value="ld"></el-option>
+                    <el-option label="标清" value="hls"></el-option>
+                  </el-select>
+                </div>
+              </el-col>
+            </el-row>
+          </div>
+        </div>
+      </div>
+
+      <div class="panel-toggle" @click="showPanel = !showPanel">
+        <i :class="showPanel ? 'el-icon-d-arrow-right' : 'el-icon-d-arrow-left'"></i>
+      </div>
+
+      <div class="pc-player-right" v-show="showPanel">
+        <div class="playlist-header">
+          <div class="playlist-title">
+            <i class="el-icon-video-list"></i>
+            课程列表
+          </div>
+          <div class="playlist-header-row">
+            <div class="playlist-count">
+              共 {{ list.length }} 个视频
+              <span class="finished-count" v-if="finishedCount">,已完成 {{ finishedCount }} 个</span>
+            </div>
+            <el-switch
+              v-model="hideFinished"
+              active-text="隐藏已完成"
+              inactive-text=""
+              size="small"
+            ></el-switch>
+          </div>
+        </div>
+        <div class="playlist-body">
+          <div class="playlist-item" v-for="(item, index) in filteredList" :key="item.id" :class="{
+              'is-active': media.id === item.id,
+              'is-finished': item.isFinish == 1,
+              'is-current-playing': media.id === item.id && onPlay
+            }" @click="loadMedia(item)">
+            <div class="item-index" :class="{ 'active-index': media.id === item.id }">
+              <span v-if="item.isFinish == 1" class="finish-badge">
+                <i class="el-icon-check"></i>
+              </span>
+              <span v-else>{{ index + 1 }}</span>
+            </div>
+            <div class="item-info">
+              <div class="item-name">{{ item.name }}</div>
+              <div class="item-meta">
+                <span class="item-duration">学时:{{ item.xs / 10 }}</span>
+                <span class="item-progress">{{ (item.position * 100 / item.duration).toFixed(1) }}%</span>
+              </div>
+            </div>
+            <div class="item-action">
+              <el-button v-if="media.id === item.id && onPlay" type="warning" size="mini" icon="el-icon-video-pause"
+                circle @click.stop="doPause"></el-button>
+              <el-button v-else-if="media.id === item.id" type="primary" size="mini" icon="el-icon-video-play" circle
+                @click.stop="doPlay"></el-button>
+              <el-button v-else type="text" size="mini" icon="el-icon-video-play"
+                @click.stop="loadMedia(item)">播放</el-button>
+            </div>
+          </div>
+          <div class="playlist-empty" v-if="list.length === 0">
+            <i class="el-icon-document"></i>
+            <p>暂无视频</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+  import { mapActions } from "vuex";
+  import {
+    getPercent
+  } from '@/utils/index.js'
+  import {
+    videoPlayer
+  } from 'vue-video-player';
+  import 'video.js/dist/video-js.css'
+  import {
+    httpServer
+  } from "@/components/httpServer/httpServer.js";
+  export default {
+    data() {
+      return {
+        courseId: 0,
+        mediaId: 0,
+        media: {},
+        options: {
+          controls: true,
+          autoplay: true,
+          muted: true,
+          loop: false,
+          preload: "auto",
+          language: 'zh-CN',
+          aspectRatio: '16:9',
+          fluid: true,
+          sources: [],
+          poster: '',
+          notSupportedMessage: '无法播放媒体源',
+          playtimes: '',
+          controlBar: {
+            timeDivider: true,
+            durationDisplay: true,
+            remainingTimeDisplay: false,
+            fullscreenToggle: true
+          }
+        },
+        curTimes: 0,
+        tpl: {},
+        info: {isBan:0},
+        list: [],
+        timer: false,
+        tickNum: 0,
+        prevTime: 0,
+        isReady: false,
+        onPlay: false,
+        volume: 0.5,
+        isMuted: false,
+        mediaType: 'hls',
+        hasSeeked: false,
+        hideFinished: false,
+        showPanel: true
+      };
+    },
+    components: {
+      videoPlayer
+    },
+    computed: {
+      finishedCount() {
+        return this.list.filter(i => i.isFinish == 1).length;
+      },
+      filteredList() {
+        if (this.hideFinished) {
+          return this.list.filter(i => i.isFinish != 1)
+        }
+        return this.list
+      },
+      playPercent() {
+        if (!this.media.duration) return 0;
+        return +((this.curTimes / this.media.duration) * 100).toFixed(1);
+      }
+    },
+    filters: {
+      useTime(val) {
+        val = parseInt(val) || 0
+        let timestr = ""
+        let hour = parseInt(val / 3600);
+        let min = parseInt(val / 60 % 60);
+        let sec = parseInt(val % 60);
+        if (hour < 10) hour = "0" + hour;
+        if (min < 10) min = "0" + min;
+        if (sec < 10) sec = "0" + sec;
+        return hour + ":" + min + ":" + sec
+      }
+    },
+    methods: {
+      getCourse() {
+        let param = {
+          courseId: this.courseId
+        }
+        httpServer("course.getCourse", param).then(res => {
+          if (res.code == 200) {
+            let {
+              info,
+              extra,
+              list,
+              tpl
+            } = res.data;
+            this.info = Object.assign(info, extra || {});
+            this.tpl = tpl || {};
+            this.list = list.map((item) => {
+              item.percent = getPercent(item) || 0;
+              return item;
+            })
+            let target = null
+            if (this.mediaId) {
+              target = this.list.find(i => i.id == this.mediaId || i.mediaId == this.mediaId)
+            }
+            if (!target) {
+              target = this.list.find(i => i.isFinish != 1)
+            }
+            if (!target && this.list.length) {
+              target = this.list[0]
+            }
+            if (target) {
+              this.loadMedia(target)
+            }
+          }
+        })
+      },
+      setVolume(val) {
+        this.volume = val
+        this.isMuted = val === 0
+        let player = this.$refs.videoPlayer && this.$refs.videoPlayer.player
+        if (player && player.volume) {
+          player.volume(val)
+          player.muted(this.isMuted)
+        }
+      },
+      changeMedia(val) {
+        if (!val || !this.mediaUrl) return;
+        let mediaUrl = this.mediaUrl;
+        if (val == 'ld') {
+          mediaUrl = mediaUrl.replace('/hls/', '/ld/');
+        } else {
+          mediaUrl = mediaUrl.replace('/ld/', '/hls/');
+        }
+        this.mediaType = val;
+        this.stopTick()
+        this.onPlay = false
+        this.curTimes = 0
+        this.hasSeeked = false
+        this.options = {
+          ...this.options,
+          sources: [{ src: mediaUrl, type: "application/x-mpegURL" }],
+          playtimes: this.media.position || 0,
+          autoplay: true
+        }
+      },
+      startTick() {
+        let tick = this.tryTick;
+        if (this.timer) clearInterval(this.timer);
+        this.timer = setInterval(tick, 5 * 1000);
+      },
+      stopTick() {
+        if (this.timer) clearInterval(this.timer);
+      },
+      tryTick() {
+        let that = this;
+        try {
+          that.tick()
+        } catch (err) {
+          that.reportErr("play", '' + err.message)
+        }
+      },
+      playerReadied(audio) {
+        this.isReady = true
+      },
+      onPlayerError(event) {
+        console.error("Video error:", event)
+      },
+      onPlayerTimeupdate(player) {
+        let curTimes = player.currentTime();
+        this.curTimes = curTimes || 0
+        if (this.media.isFinish) {
+          return;
+        }
+      },
+      safePlay(player) {
+        if (!player) return
+        try {
+          const p = player.play()
+          if (p && p.catch) p.catch(() => {})
+        } catch (e) {}
+      },
+      setposition(position) {
+        if (position > this.media.duration) position = this.media.duration;
+        let player = this.$refs.videoPlayer.player;
+        player.currentTime(position);
+        this.safePlay(player)
+        if (this.media.isFinish) return;
+        if (this.media.position >= this.media.duration - 10 && !this.media.isFinish) {
+          this.tick(true)
+        }
+      },
+      onPlayerPause(event) {
+        this.reportErr("play", 'pause');
+        this.stopTick()
+        this.onPlay = false
+      },
+      onPlayerEnded(event) {
+        this.reportErr("play", 'end');
+        this.tick(true)
+        if (this.info.isBan > 1) {
+          this.playNext()
+        }
+      },
+      playNext() {
+        let currentIndex = this.list.findIndex(i => i.id === this.media.id)
+        let nextItem = null
+        for (let i = currentIndex + 1; i < this.list.length; i++) {
+          if (this.list[i].isFinish != 1) {
+            nextItem = this.list[i]
+            break
+          }
+        }
+        if (!nextItem) {
+          for (let i = 0; i < currentIndex; i++) {
+            if (this.list[i].isFinish != 1) {
+              nextItem = this.list[i]
+              break
+            }
+          }
+        }
+        if (nextItem) {
+          this.$message.success(`即将播放: ${nextItem.name}`)
+          setTimeout(() => {
+            this.loadMedia(nextItem)
+          }, 1000)
+        }
+      },
+      onClose() {
+        this.reportErr("play", 'close')
+        this.doPause()
+      },
+      doPause() {
+        this.stopTick()
+        this.onPlay = false
+        let myPlayer = this.$refs.videoPlayer.player;
+        myPlayer && myPlayer.pause()
+      },
+      doPlay() {
+        this.onPlay = true
+        this.startTick();
+        if (!this.$refs.videoPlayer || !this.$refs.videoPlayer.player) return;
+        this.safePlay(this.$refs.videoPlayer.player)
+        this.tickNum = 0
+      },
+      onPlayerStart() {
+        this.reportErr("play", 'start');
+        this.startTick()
+        this.onPlay = true
+        if (!this.hasSeeked && this.media.position > 5 && this.media.position < this.media.duration - 10) {
+          this.hasSeeked = true
+          let player = this.$refs.videoPlayer && this.$refs.videoPlayer.player
+          if (player && player.currentTime) {
+            player.currentTime(this.media.position)
+          }
+        }
+      },
+      toggleMute() {
+        this.isMuted = !this.isMuted
+        let player = this.$refs.videoPlayer && this.$refs.videoPlayer.player
+        if (player && player.volume) {
+          player.muted(this.isMuted)
+        }
+      },
+      reportErr(action, msg) {
+        httpServer("course.report", { action, msg }, true)
+      },
+      tick(force = false) {
+        let media = this.media;
+        this.tickNum++
+        if (this.media.isFinish) {
+          return;
+        }
+        let myPlayer = this.$refs.videoPlayer.player;
+        if (!myPlayer) return;
+        let curTimes = parseInt(myPlayer.currentTime());
+        if (curTimes < 4) {
+          return;
+        }
+        if (this.media.position > curTimes + 5 && !force) return;
+        let isFinish = force ? 1 : 0
+        if (curTimes >= media.duration) isFinish = 1;
+        if (!isFinish) {
+          if (!this.onPlay) return;
+        }
+        if (window.navigator.webdriver) {
+          this.reportErr("play", 'webdriver');
+          this.doPause()
+          return
+        }
+        let param = {
+          id: media.id,
+          position: curTimes,
+          isFinish
+        };
+        httpServer("course.tick", param, true).then(res => {
+          if (res.code == 200) {
+            let {
+              skip,
+              position,
+              pause,
+              closed
+            } = res.data
+            if (pause || closed) {
+              this.doPause();
+              if (closed) {
+                this.$message.errorMsg("禁止多处同时学习", 5);
+              } else if (pause) {
+                this.$message.errorMsg("禁止多处同时学习", 5);
+              }
+              return
+            }
+            if (!skip) {
+              let curTimes = parseInt(myPlayer.currentTime())
+              if (position < curTimes + 5) {
+                this.setposition(position)
+              }
+            }
+            Object.assign(this.media, res.data)
+          }
+        })
+      },
+      loadMedia(item) {
+        this.media = item;
+        httpServer('course.GetMedia', {
+          id: item.id
+        }).then(res => {
+          if (res.code != 200) return;
+          let {
+            mediaUrl,
+            id,
+            position,
+            marks,
+            duration
+          } = res.data || {};
+          this.mediaUrl = mediaUrl;
+          this.media.position = position;
+          this.media.duration = duration;
+          this.media.id = id;
+          this.curTimes = 0
+          this.onPlay = false
+          this.hasSeeked = false
+          this.stopTick()
+          this.options = {
+            ...this.options,
+            sources: [{
+              src: mediaUrl,
+              type: "application/x-mpegURL"
+            }],
+            playtimes: position || 1,
+            autoplay: true
+          }
+        });
+      }
+    },
+    created() {
+      let { uid, token, courseId, mediaId } = this.$route.query
+      this.courseId = +courseId || +this.$route.params.courseId
+      this.mediaId = mediaId || this.$route.query.mediaId || 0
+      if (uid) localStorage.uid = uid
+      if (token) localStorage.token = token
+      if (this.courseId) {
+        this.getCourse()
+      }
+    },
+    beforeDestroy() {
+      this.stopTick()
+      this.reportErr("play", 'destroy')
+    }
+  }
+</script>
+<style>
+  @import url("./play.css");
+
+  .pc-player-container {
+    min-height: 100vh;
+    background: #f0f2f5;
+    padding: 20px;
+    box-sizing: border-box;
+  }
+
+  .pc-player-wrapper {
+    max-width: 1440px;
+    margin: 0 auto;
+    display: flex;
+    gap: 20px;
+    height: calc(100vh - 40px);
+  }
+
+  .pc-player-left {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .panel-toggle {
+    flex-shrink: 0;
+    width: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    color: #999;
+    font-size: 14px;
+    user-select: none;
+    transition: color 0.2s;
+  }
+
+  .panel-toggle:hover {
+    color: #409EFF;
+  }
+
+  .video-section {
+    background: #fff;
+    border-radius: 12px;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+    overflow: hidden;
+  }
+
+  .video-title {
+    padding: 16px 20px;
+    font-size: 16px;
+    font-weight: 600;
+    color: #1a1a2e;
+    border-bottom: 1px solid #f0f0f0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .video-player-wrap {
+    width: 100%;
+    background: #000;
+    touch-action: none;
+  }
+
+  .video-player-wrap .video-js {
+    width: 100%;
+  }
+
+  .video-info-bar {
+    padding: 12px 20px;
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    border-top: 1px solid #f0f0f0;
+  }
+
+  .video-time {
+    font-size: 14px;
+    color: #666;
+    white-space: nowrap;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .time-current {
+    color: #409EFF;
+    font-weight: 600;
+  }
+
+  .time-sep {
+    margin: 0 4px;
+    color: #ccc;
+  }
+
+  .video-progress {
+    flex: 1;
+    min-width: 100px;
+  }
+
+  .video-status {
+    flex-shrink: 0;
+  }
+
+  .pc-player-right {
+    width: 380px;
+    flex-shrink: 0;
+    background: #fff;
+    border-radius: 12px;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .playlist-header {
+    padding: 16px 20px 12px;
+    border-bottom: 1px solid #f0f0f0;
+    flex-shrink: 0;
+  }
+
+  .playlist-header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-top: 6px;
+  }
+
+  .playlist-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #1a1a2e;
+    margin-bottom: 4px;
+  }
+
+  .playlist-title i {
+    margin-right: 6px;
+    color: #409EFF;
+  }
+
+  .playlist-count {
+    font-size: 12px;
+    color: #999;
+  }
+
+  .finished-count {
+    color: #67C23A;
+  }
+
+  .playlist-body {
+    flex: 1;
+    overflow-y: auto;
+    padding: 8px 0;
+  }
+
+  .playlist-body::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  .playlist-body::-webkit-scrollbar-thumb {
+    background: #ddd;
+    border-radius: 3px;
+  }
+
+  .playlist-body::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  .playlist-item {
+    display: flex;
+    align-items: center;
+    padding: 12px 20px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    border-left: 3px solid transparent;
+    gap: 12px;
+  }
+
+  .playlist-item:hover {
+    background: #f5f7fa;
+  }
+
+  .playlist-item.is-active {
+    background: #ecf5ff;
+    border-left-color: #409EFF;
+  }
+
+  .playlist-item.is-finished {
+    opacity: 0.65;
+  }
+
+  .playlist-item.is-current-playing {
+    background: #ecf5ff;
+    border-left-color: #409EFF;
+  }
+
+  .item-index {
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    background: #f0f2f5;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 12px;
+    font-weight: 600;
+    color: #666;
+    flex-shrink: 0;
+    transition: all 0.2s;
+  }
+
+  .item-index.active-index {
+    background: #409EFF;
+    color: #fff;
+  }
+
+  .finish-badge {
+    color: #67C23A;
+    font-size: 14px;
+  }
+
+  .item-info {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .item-name {
+    font-size: 14px;
+    color: #333;
+    font-weight: 500;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    margin-bottom: 2px;
+  }
+
+  .is-finished .item-name {
+    color: #999;
+    text-decoration: line-through;
+  }
+
+  .is-active .item-name {
+    color: #409EFF;
+  }
+
+  .item-meta {
+    display: flex;
+    gap: 12px;
+    font-size: 12px;
+    color: #999;
+  }
+
+  .item-progress {
+    color: #409EFF;
+  }
+
+  .is-finished .item-progress {
+    color: #67C23A;
+  }
+
+  .item-action {
+    flex-shrink: 0;
+  }
+
+  .playlist-empty {
+    text-align: center;
+    padding: 60px 20px;
+    color: #ccc;
+  }
+
+  .playlist-empty i {
+    font-size: 48px;
+    display: block;
+    margin-bottom: 12px;
+  }
+
+  .playlist-empty p {
+    font-size: 14px;
+    margin: 0;
+  }
+
+  .dialog-footer {
+  padding: 0 16px;
+  border-top: 1px solid #f0f0f0;
+  background: #fafafa;
+}
+.dialog-footer.pt30 {
+  padding: 8px 16px;
+}
+.media-footer {
+  display: flex;
+  align-items: center;
+  line-height: 1;
+}
+.media-time {
+  font-size: 14px;
+  color: #666;
+  white-space: nowrap;
+  font-variant-numeric: tabular-nums;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.media-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.bicon {
+  font-size: 22px !important;
+  padding: 4px !important;
+}
+.volume-control {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.vol-icon {
+  font-size: 12px;
+  font-weight: 600;
+  cursor: pointer;
+  color: #606266;
+  user-select: none;
+  line-height: 1;
+}
+.vol-icon:hover {
+  color: #409EFF;
+}
+.volume-slider {
+  width: 300px !important;
+}
+.volume-slider .el-slider__runway {
+  height: 4px;
+}
+.volume-slider .el-slider__bar {
+  height: 4px;
+}
+.volume-slider .el-slider__button {
+  width: 12px;
+  height: 12px;
+}
+.media-select {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+}
+.media-el-select {
+  width: 80px;
+}
+.media-el-select .el-input__inner {
+  height: 28px;
+  line-height: 28px;
+  font-size: 12px;
+}
+
+@media (max-width: 900px) {
+    .pc-player-wrapper {
+      flex-direction: column;
+      height: auto;
+    }
+
+    .pc-player-right {
+      width: 100%;
+      max-height: 400px;
+    }
+
+    .pc-player-container {
+      padding: 12px;
+    }
+  }
+</style>