Compare commits

..

No commits in common. "main" and "72Player" have entirely different histories.

15 changed files with 364 additions and 1107 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
.DS_Store .DS_Store
.pw-user-data .pw-user-data
*.zip
node_modules/ node_modules/
dist/ dist/
npm-debug.log* npm-debug.log*

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"year": 2024, "year": 2024,
"updatedAt": "2026-03-25T01:35:17.796Z", "updatedAt": "2026-03-10T21:35:04.482Z",
"items": [ "items": [
{ {
"id": "7f012566d7827b5046de9f92a4d7e159", "id": "7f012566d7827b5046de9f92a4d7e159",

View File

@ -1,6 +1,6 @@
{ {
"year": 2025, "year": 2025,
"updatedAt": "2026-03-25T01:34:35.851Z", "updatedAt": "2026-03-10T21:34:22.356Z",
"items": [ "items": [
{ {
"id": "b2b4f80b846360647d13e297c029be8d", "id": "b2b4f80b846360647d13e297c029be8d",

View File

@ -1,6 +1,6 @@
{ {
"year": 2026, "year": 2026,
"updatedAt": "2026-03-25T01:33:45.340Z", "updatedAt": "2026-03-10T21:33:31.594Z",
"items": [ "items": [
{ {
"id": "5abbc88ea530b5db6438a4e584e80281", "id": "5abbc88ea530b5db6438a4e584e80281",

View File

@ -1,5 +1,5 @@
{ {
"running": false, "running": false,
"lastMessage": "自动采集完成", "lastMessage": "自动采集完成",
"updatedAt": "2026-03-25T01:35:31.093Z" "updatedAt": "2026-03-10T21:35:26.989Z"
} }

View File

@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"artplayer": "^5.3.0", "artplayer": "^5.3.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"bootstrap": "^5.3.8",
"cors": "^2.8.6", "cors": "^2.8.6",
"express": "^5.2.1", "express": "^5.2.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",

View File

@ -9,18 +9,25 @@ export interface EpisodeData {
videoUrl: string videoUrl: string
} }
export interface QishierAllResponse { export interface QishierListResponse {
updatedAt: string | null updatedAt: string | null
name: string name: string
coverUrl: string coverUrl: string
displayType: number displayType: number
total: number total: number
years: number[] years: number[]
page: number
pageSize: number
hasMore: boolean
list: EpisodeData[] list: EpisodeData[]
} }
export function getAllQishierList() { export function getQishierList(page = 1, pageSize = 50) {
return axios.get<QishierAllResponse>('/api/qishier/all', { return axios.get<QishierListResponse>('/api/qishier/list', {
params: {
page,
pageSize
},
timeout: 15000 timeout: 15000
}) })
} }

View File

@ -1,115 +1,45 @@
<template> <template>
<div class="episode-card card border-0 shadow-sm h-100"> <div ref="listPanelRef" class="list-panel">
<div class="card-header bg-transparent border-0 pt-3 pb-2 px-3"> <div
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap"> v-for="item in episodes"
<div class="fw-bold text-dark">剧集列表</div> :key="item.id"
:data-episode-id="item.id"
<select v-model="innerYear" class="form-select form-select-sm year-select"> class="episode-item"
<option value="all">全部年份</option> :class="{ active: currentEpisodeId === item.id }"
<option v-for="year in years" :key="year" :value="String(year)"> @click="$emit('select', item)"
{{ year }} >
</option> <img :src="item.coverUrl" alt="" loading="lazy" />
</select> <div class="episode-text">
<div class="title">{{ item.title }}</div>
<div class="meta">{{ formatDate(item.releasedAt) }}</div>
</div> </div>
</div> </div>
<div ref="listPanelRef" class="card-body pt-2 episode-panel"> <div class="load-more-wrap" v-if="hasMore">
<button <button class="load-more-btn" :disabled="loadingMore" @click="$emit('loadMore')">
v-for="item in pagedEpisodes" {{ loadingMore ? '加载中...' : '加载更多' }}
:key="item.id"
:data-episode-id="item.id"
type="button"
class="episode-row btn w-100 text-start mb-2"
:class="currentEpisodeId === item.id ? 'btn-warning current-row' : 'btn-light'"
@click="handleSelect(item, $event)"
>
<div class="episode-date mb-1">{{ formatDate(item.releasedAt) }}</div>
<div class="episode-title">{{ item.title }}</div>
</button> </button>
<div v-if="pagedEpisodes.length === 0" class="empty-tip text-center py-4">
当前筛选没有数据
</div>
</div>
<div class="card-footer bg-transparent border-0 px-3 pb-3 pt-1">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="small text-secondary">
{{ filteredEpisodes.length }} 当前第 {{ currentPage }} / {{ totalPages || 1 }}
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="prevPage">
上一页
</button>
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="nextPage">
下一页
</button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue' import { nextTick, ref, watch } from 'vue'
import type { EpisodeData } from '@/api/qishier' import type { EpisodeData } from '@/api/qishier'
const props = defineProps<{ const props = defineProps<{
episodes: EpisodeData[] episodes: EpisodeData[]
currentEpisodeId: string currentEpisodeId: string
years: number[] hasMore: boolean
selectedYear: string loadingMore: boolean
}>() }>()
const emit = defineEmits<{ defineEmits<{
select: [episode: EpisodeData] select: [episode: EpisodeData]
updateYear: [year: string] loadMore: []
}>() }>()
const listPanelRef = ref<HTMLElement | null>(null) const listPanelRef = ref<HTMLElement | null>(null)
const innerYear = ref(props.selectedYear || 'all')
const pageSize = 20
const currentPage = ref(1)
watch(
() => props.selectedYear,
(val) => {
const nextVal = val || 'all'
if (innerYear.value !== nextVal) {
innerYear.value = nextVal
}
}
)
watch(innerYear, (val) => {
if (val === props.selectedYear) return
currentPage.value = 1
emit('updateYear', val)
})
const filteredEpisodes = computed(() => {
if (innerYear.value === 'all') return props.episodes
const year = Number(innerYear.value)
return props.episodes.filter(item => new Date(item.releasedAt).getFullYear() === year)
})
const totalPages = computed(() => {
return Math.ceil(filteredEpisodes.value.length / pageSize)
})
const pagedEpisodes = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredEpisodes.value.slice(start, start + pageSize)
})
function prevPage() {
if (currentPage.value > 1) currentPage.value--
}
function nextPage() {
if (currentPage.value < totalPages.value) currentPage.value++
}
function formatDate(timestamp: number): string { function formatDate(timestamp: number): string {
if (!timestamp) return '--' if (!timestamp) return '--'
@ -120,29 +50,11 @@ function formatDate(timestamp: number): string {
return `${y}-${m}-${day}` return `${y}-${m}-${day}`
} }
function isMobileDevice() { async function scrollToActiveEpisode() {
return window.innerWidth < 992 await nextTick()
}
function handleSelect(item: EpisodeData, event: MouseEvent) {
;(event.currentTarget as HTMLButtonElement | null)?.blur()
emit('select', item)
}
async function locateCurrentEpisode() {
if (!props.currentEpisodeId) return
const fullList = filteredEpisodes.value
const idx = fullList.findIndex(item => item.id === props.currentEpisodeId)
if (idx < 0) return
const targetPage = Math.floor(idx / pageSize) + 1
if (currentPage.value !== targetPage) {
currentPage.value = targetPage
await nextTick()
}
const panel = listPanelRef.value const panel = listPanelRef.value
if (!panel) return if (!panel || !props.currentEpisodeId) return
const activeEl = panel.querySelector( const activeEl = panel.querySelector(
`[data-episode-id="${props.currentEpisodeId}"]` `[data-episode-id="${props.currentEpisodeId}"]`
@ -150,31 +62,24 @@ async function locateCurrentEpisode() {
if (!activeEl) return if (!activeEl) return
// const panelRect = panel.getBoundingClientRect()
if (isMobileDevice()) { const itemRect = activeEl.getBoundingClientRect()
const panelTop = panel.scrollTop
const itemTop = activeEl.offsetTop
const itemHeight = activeEl.offsetHeight
const panelHeight = panel.clientHeight
panel.scrollTo({ const isAbove = itemRect.top < panelRect.top
top: itemTop - panelHeight / 2 + itemHeight / 2, const isBelow = itemRect.bottom > panelRect.bottom
behavior: 'smooth'
if (isAbove || isBelow) {
activeEl.scrollIntoView({
behavior: 'smooth',
block: 'center'
}) })
return
} }
activeEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
})
} }
watch( watch(
() => props.currentEpisodeId, () => props.currentEpisodeId,
async () => { async () => {
await locateCurrentEpisode() await scrollToActiveEpisode()
}, },
{ immediate: true } { immediate: true }
) )
@ -182,62 +87,92 @@ watch(
watch( watch(
() => props.episodes.length, () => props.episodes.length,
async () => { async () => {
await locateCurrentEpisode() await scrollToActiveEpisode()
}
)
watch(
() => props.selectedYear,
async () => {
await locateCurrentEpisode()
} }
) )
</script> </script>
<style scoped> <style scoped>
.episode-card { .list-panel {
border-radius: 18px; max-height: 80vh;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
}
.episode-panel {
max-height: 72vh;
overflow-y: auto; overflow-y: auto;
padding: 12px;
background: #1b1b1b;
border-radius: 12px;
scroll-behavior: smooth;
} }
.episode-row { .episode-item {
border-radius: 14px; display: flex;
border: 1px solid rgba(0, 0, 0, 0.06); gap: 12px;
padding: 12px 14px; padding: 10px;
transition: all 0.2s ease; border-radius: 10px;
cursor: pointer;
margin-bottom: 10px;
transition: 0.2s;
border: 1px solid transparent;
} }
.episode-row:hover { .episode-item:hover {
transform: translateY(-1px); background: rgba(255, 255, 255, 0.06);
} }
.current-row { .episode-item.active {
box-shadow: 0 8px 20px rgba(255, 193, 7, 0.22); background: rgba(255, 193, 7, 0.16);
border-color: rgba(255, 193, 7, 0.45);
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.12) inset;
} }
.episode-date { .episode-item img {
font-size: 12px;
color: #6c757d;
}
.episode-title {
line-height: 1.45;
font-size: 14px;
font-weight: 600;
white-space: normal;
}
.year-select {
width: 120px; width: 120px;
height: 68px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
} }
.empty-tip { .episode-text {
color: #6c757d; flex: 1;
min-width: 0;
}
.title {
font-size: 15px;
line-height: 1.5;
margin-bottom: 8px;
font-weight: 600;
color: #fff;
}
.episode-item.active .title {
color: #ffd666;
}
.meta {
font-size: 13px;
color: #999;
}
.load-more-wrap {
padding: 12px 0 4px;
text-align: center;
}
.load-more-btn {
padding: 10px 18px;
border: none;
border-radius: 8px;
background: #333;
color: #fff;
cursor: pointer;
}
.load-more-btn:hover {
background: #444;
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
</style> </style>

View File

@ -1,155 +0,0 @@
<template>
<section class="project-links-section">
<div class="project-links-card">
<div class="project-links-left">
<div class="project-links-badge">项目源码</div>
<h4 class="project-links-title">72QishierPlayer 开源仓库</h4>
<p class="project-links-desc">
包含前端播放器后端采集服务数据缓存播放记录与剧集列表逻辑方便部署与二次开发
</p>
<div class="project-links-url">
<span class="label">仓库地址</span>
<a
:href="gitUrl"
target="_blank"
rel="noopener noreferrer"
class="git-url-link"
>
{{ gitUrl }}
</a>
</div>
<div class="project-links-code">
<code>git clone {{ gitUrl }}</code>
</div>
</div>
<div class="project-links-right">
<a
class="btn btn-dark btn-sm px-3"
:href="gitUrl"
target="_blank"
rel="noopener noreferrer"
>
打开仓库
</a>
<button class="btn btn-outline-secondary btn-sm px-3" @click="copyGitUrl">
复制链接
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const gitUrl = 'https://git.a-hxin.cn/ahxin/72QishierPlayer.git'
async function copyGitUrl() {
try {
await navigator.clipboard.writeText(gitUrl)
alert('Git 仓库链接已复制')
} catch {
alert('复制失败,请手动复制')
}
}
</script>
<style scoped>
.project-links-section {
margin-top: 18px;
}
.project-links-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
padding: 18px 20px;
border-radius: 18px;
background: linear-gradient(135deg, #ffffff 0%, #f7f8fa 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.project-links-left {
flex: 1;
min-width: 0;
}
.project-links-badge {
display: inline-block;
padding: 5px 10px;
margin-bottom: 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: #0d6efd;
background: rgba(13, 110, 253, 0.1);
}
.project-links-title {
margin-bottom: 8px;
font-size: 20px;
font-weight: 700;
color: #212529;
}
.project-links-desc {
margin-bottom: 12px;
font-size: 14px;
line-height: 1.7;
color: #6c757d;
}
.project-links-url {
margin-bottom: 10px;
font-size: 14px;
line-height: 1.7;
word-break: break-all;
}
.project-links-url .label {
color: #6c757d;
}
.git-url-link {
color: #0d6efd;
text-decoration: none;
}
.git-url-link:hover {
text-decoration: underline;
}
.project-links-code {
padding: 10px 12px;
border-radius: 12px;
background: #f1f3f5;
overflow-x: auto;
}
.project-links-code code {
color: #212529;
font-size: 13px;
white-space: nowrap;
}
.project-links-right {
display: flex;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
}
@media (max-width: 768px) {
.project-links-card {
flex-direction: column;
align-items: flex-start;
}
.project-links-right {
width: 100%;
}
}
</style>

View File

@ -1,362 +0,0 @@
<template>
<section class="showcase-section" v-if="episodes.length > 0">
<div class="section-head d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="section-title mb-1">推荐展示</h4>
<!--<div class="section-subtitle">基于当前节目数据生成展示内容</div>-->
</div>
<button
class="btn btn-sm btn-outline-secondary d-none d-lg-inline-flex"
v-if="featuredEpisode"
@click="$emit('select', featuredEpisode)"
>
立即播放
</button>
</div>
<!-- 电脑端 -->
<div class="desktop-showcase d-none d-lg-block">
<div class="row g-3">
<div class="col-12 col-xl-8" v-if="featuredEpisode">
<div class="featured-card" @click="$emit('select', featuredEpisode)">
<div
class="featured-cover"
:style="coverStyle(featuredEpisode.coverUrl)"
></div>
<div class="featured-content">
<div class="featured-badge">精选推荐</div>
<h3 class="featured-title">{{ featuredEpisode.title }}</h3>
<p class="featured-desc">
发布时间{{ formatDate(featuredEpisode.releasedAt) }}
<span class="mx-2"></span>
时长{{ formatDuration(featuredEpisode.timeLength) }}
</p>
<div class="featured-meta">
<span>自动播放</span>
<span>支持续播</span>
<span>高清资源</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="side-panel">
<div
v-for="item in sideList"
:key="item.id"
class="side-item"
@click="$emit('select', item)"
>
<div
class="side-thumb"
:style="coverStyle(item.coverUrl)"
></div>
<div class="side-text">
<div class="side-item-title">{{ item.title }}</div>
<div class="side-item-meta">
{{ formatDate(item.releasedAt) }} {{ formatDuration(item.timeLength) }}
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="row g-3">
<div
v-for="item in bottomList"
:key="item.id"
class="col-md-6 col-xl-3"
>
<div class="mini-card" @click="$emit('select', item)">
<div
class="mini-cover"
:style="coverStyle(item.coverUrl)"
></div>
<div class="mini-title">{{ item.title }}</div>
<div class="mini-meta">
{{ formatDate(item.releasedAt) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 手机端 -->
<div class="mobile-showcase d-lg-none">
<div class="mobile-grid">
<div
v-for="item in mobileList"
:key="item.id"
class="mobile-card"
@click="$emit('select', item)"
>
<div
class="mobile-cover"
:style="coverStyle(item.coverUrl)"
></div>
<div class="mobile-title">{{ item.title }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { EpisodeData } from '@/api/qishier'
const props = defineProps<{
episodes: EpisodeData[]
}>()
defineEmits<{
select: [episode: EpisodeData]
}>()
const featuredEpisode = computed(() => props.episodes[0] || null)
const sideList = computed(() => props.episodes.slice(1, 4))
const bottomList = computed(() => props.episodes.slice(4, 8))
const mobileList = computed(() => props.episodes.slice(0, 6))
function coverStyle(url?: string) {
if (!url) {
return {
background: '#dcdcdc'
}
}
return {
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}
}
function formatDate(timestamp: number): string {
if (!timestamp) return '--'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function formatDuration(seconds: number): string {
if (!seconds && seconds !== 0) return '--'
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
</script>
<style scoped>
.showcase-section {
margin-top: 18px;
}
.section-title {
font-size: 22px;
font-weight: 700;
color: #212529;
}
.section-subtitle {
font-size: 13px;
color: #6c757d;
}
.featured-card {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 20px;
min-height: 280px;
padding: 20px;
border-radius: 20px;
background: linear-gradient(135deg, #ffffff 0%, #f5f7fa 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.featured-cover,
.side-thumb,
.mini-cover,
.mobile-cover {
background-color: #dcdcdc;
background-size: cover;
background-position: center;
}
.featured-cover {
border-radius: 18px;
min-height: 240px;
}
.featured-content {
display: flex;
flex-direction: column;
justify-content: center;
}
.featured-badge {
display: inline-block;
width: fit-content;
padding: 6px 12px;
margin-bottom: 12px;
border-radius: 999px;
font-size: 12px;
color: #8a5a00;
background: rgba(255, 193, 7, 0.18);
}
.featured-title {
margin-bottom: 12px;
font-size: 28px;
line-height: 1.35;
font-weight: 800;
color: #212529;
}
.featured-desc {
margin-bottom: 14px;
font-size: 15px;
line-height: 1.8;
color: #6c757d;
}
.featured-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.featured-meta span {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
color: #495057;
background: #eef1f4;
}
.side-panel {
height: 100%;
padding: 16px;
border-radius: 20px;
background: linear-gradient(135deg, #ffffff 0%, #f7f8fa 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.side-item {
display: flex;
gap: 12px;
padding: 10px 0;
cursor: pointer;
}
.side-item + .side-item {
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.side-thumb {
width: 110px;
height: 66px;
border-radius: 12px;
flex-shrink: 0;
}
.side-text {
min-width: 0;
}
.side-item-title {
font-size: 14px;
font-weight: 700;
color: #212529;
line-height: 1.5;
margin-bottom: 6px;
}
.side-item-meta {
font-size: 12px;
color: #6c757d;
}
.mini-card {
padding: 14px;
border-radius: 18px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fb 100%);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.05);
height: 100%;
cursor: pointer;
}
.mini-cover {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
margin-bottom: 12px;
}
.mini-title {
font-size: 15px;
font-weight: 700;
color: #212529;
line-height: 1.5;
margin-bottom: 6px;
}
.mini-meta {
font-size: 12px;
color: #6c757d;
}
/* 手机端 */
.mobile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.mobile-card {
display: flex;
flex-direction: column;
cursor: pointer;
}
.mobile-cover {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 16px;
margin-bottom: 10px;
}
.mobile-title {
font-size: 15px;
line-height: 1.45;
font-weight: 600;
color: #3a3a3a;
word-break: break-word;
}
@media (max-width: 767px) {
.showcase-section {
margin-top: 14px;
}
.section-title {
font-size: 18px;
}
.mobile-grid {
gap: 12px;
}
.mobile-title {
font-size: 14px;
}
}
</style>

View File

@ -11,8 +11,6 @@ import Hls from 'hls.js'
import type { EpisodeData } from '@/api/qishier' import type { EpisodeData } from '@/api/qishier'
import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory' import { getEpisodeProgress, saveEpisodeProgress } from '@/utils/playHistory'
type ArtWithHls = Artplayer & { type ArtWithHls = Artplayer & {
hls?: Hls hls?: Hls
} }
@ -29,18 +27,8 @@ const emit = defineEmits<{
const playerRef = ref<HTMLDivElement | null>(null) const playerRef = ref<HTMLDivElement | null>(null)
let art: ArtWithHls | null = null let art: ArtWithHls | null = null
let playRetryTimer: number | null = null
function clearPlayRetryTimer() {
if (playRetryTimer !== null) {
window.clearTimeout(playRetryTimer)
playRetryTimer = null
}
}
function destroyPlayer() { function destroyPlayer() {
clearPlayRetryTimer()
if (art?.hls) { if (art?.hls) {
art.hls.destroy() art.hls.destroy()
art.hls = undefined art.hls = undefined
@ -56,16 +44,6 @@ function destroyPlayer() {
} }
} }
function safeAutoPlay() {
clearPlayRetryTimer()
playRetryTimer = window.setTimeout(() => {
void art?.video?.play().catch(() => {
//
})
}, 180)
}
function buildPlayer(episode: EpisodeData) { function buildPlayer(episode: EpisodeData) {
if (!playerRef.value) return if (!playerRef.value) return
@ -83,53 +61,17 @@ function buildPlayer(episode: EpisodeData) {
mutex: true, mutex: true,
theme: '#f5c542', theme: '#f5c542',
moreVideoAttr: { moreVideoAttr: {
playsInline: true, playsInline: true
preload: 'auto'
} as any, } as any,
customType: { customType: {
m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) { m3u8(video: HTMLVideoElement, url: string, artInstance: Artplayer) {
const artplayer = artInstance as ArtWithHls const artplayer = artInstance as ArtWithHls
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls({ const hls = new Hls()
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferHole: 1,
highBufferWatchdogPeriod: 2,
nudgeOffset: 0.1,
nudgeMaxRetry: 5
})
artplayer.hls = hls artplayer.hls = hls
hls.loadSource(url) hls.loadSource(url)
hls.attachMedia(video) hls.attachMedia(video)
hls.on(Hls.Events.ERROR, (_event, data) => {
console.error('HLS Error:', data)
if (!data.fatal) {
return
}
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
console.warn('HLS NETWORK_ERROR尝试恢复')
hls.startLoad()
emit('error', '网络异常,正在尝试恢复')
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
console.warn('HLS MEDIA_ERROR尝试恢复')
hls.recoverMediaError()
emit('error', '播放异常,正在尝试恢复')
return
}
emit('error', '视频播放失败')
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url video.src = url
} else { } else {
@ -141,7 +83,7 @@ function buildPlayer(episode: EpisodeData) {
art.type = 'm3u8' art.type = 'm3u8'
const restoreProgress = () => { const restore = () => {
const progress = getEpisodeProgress(episode.id) const progress = getEpisodeProgress(episode.id)
if (progress > 0 && art?.video) { if (progress > 0 && art?.video) {
try { try {
@ -150,21 +92,9 @@ function buildPlayer(episode: EpisodeData) {
// ignore // ignore
} }
} }
safeAutoPlay()
} }
art.on('video:loadedmetadata', restoreProgress) art.on('video:loadedmetadata', restore)
art.on('video:canplay', () => {
// loadedmetadata canplay
safeAutoPlay()
})
art.on('video:waiting', () => {
//
console.warn('视频缓冲中...')
})
art.on('video:timeupdate', () => { art.on('video:timeupdate', () => {
if (art?.video) { if (art?.video) {
@ -202,7 +132,7 @@ watch(
watch( watch(
() => props.autoNext, () => props.autoNext,
() => { () => {
// // ended autoNext
} }
) )
@ -219,8 +149,6 @@ onBeforeUnmount(() => {
.player-wrap { .player-wrap {
width: 100%; width: 100%;
background: #000; background: #000;
border-radius: 12px;
overflow: hidden;
} }
.artplayer-container { .artplayer-container {
@ -233,8 +161,4 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
:deep(.art-video-player) {
background: #000;
}
</style> </style>

View File

@ -1,8 +1,5 @@
import './style.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css'
import 'bootstrap/dist/css/bootstrap.min.css'
createApp(App).mount('#app') createApp(App).mount('#app')

View File

@ -1,156 +1,97 @@
<template> <template>
<div class="container-fluid qishier-page py-3"> <div class="page">
<div class="row g-3"> <div class="header">
<!-- 顶部标题区 --> <div>
<div class="col-12"> <h1>{{ columnInfo.name || '七十二家房客播放器' }}</h1>
<div class="top-card card border-0 shadow-sm"> <p> {{ total }} 当前显示 {{ episodeList.length }} </p>
<div class="card-body d-flex flex-wrap justify-content-between align-items-start gap-3"> <p class="sub">最后更新{{ updatedAtText }}</p>
<div class="page-main-info">
<template v-if="currentEpisode">
<div class="episode-badges mb-2">
<span class="info-badge playing">当前播放</span>
<span class="meta-pill">发布时间{{ formatDate(currentEpisode.releasedAt) }}</span>
<span class="meta-pill">时长{{ formatDuration(currentEpisode.timeLength) }}</span>
</div>
<h2 class="mb-2 page-title">{{ currentEpisode.title }}</h2>
</template>
<template v-else>
<h2 class="mb-2 page-title">{{ columnInfo.name || '七十二家房客播放器' }}</h2>
<div class="episode-meta mb-2">正在加载节目数据...</div>
</template>
<div class="page-subtitle">
{{ allEpisodeList.length }}
<span class="mx-2"></span>
最后更新{{ updatedAtText }}
</div>
</div>
<div class="header-actions d-flex flex-wrap gap-2">
<button class="btn btn-dark btn-sm px-3" @click="handleRefresh">
刷新数据
</button>
<button class="btn btn-outline-dark btn-sm px-3" @click="openSource">
数据来源
</button>
<button class="btn btn-outline-primary btn-sm px-3" @click="openGitRepo">
Git仓库v163
</button>
</div>
</div>
</div>
</div> </div>
<!-- 控制区 --> <div class="header-actions">
<div class="col-12"> <button class="action-btn" @click="handleRefresh">刷新数据</button>
<div class="control-card card border-0 shadow-sm"> <button class="action-btn source-btn" @click="openSource">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-3"> 数据来源荔枝网
<div class="d-flex flex-wrap gap-2 align-items-center"> </button>
<button class="btn btn-outline-dark btn-sm" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
上一集
</button>
<button class="btn btn-outline-dark btn-sm" @click="playNextEpisode" :disabled="!hasNextEpisode">
下一集
</button>
<div class="form-check form-switch ms-1">
<input id="autoNext" v-model="autoNext" class="form-check-input" type="checkbox" />
<label class="form-check-label small" for="autoNext">自动下一集</label>
</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<select v-model="playMode" class="form-select form-select-sm mode-select">
<option value="asc">顺序播放</option>
<option value="desc">倒序播放</option>
</select>
<select v-model="selectedYear" class="form-select form-select-sm filter-select">
<option value="all">全部年份</option>
<option v-for="year in years" :key="year" :value="String(year)">
{{ year }}
</option>
</select>
<select v-model="selectedMonth" class="form-select form-select-sm filter-select">
<option value="all">全部月份</option>
<option v-for="month in months" :key="month" :value="String(month)">
{{ month }}
</option>
</select>
</div>
</div>
</div>
</div> </div>
</div>
<!-- 左侧播放器 --> <div class="toolbar">
<div class="col-xl-8 col-lg-7"> <button class="action-btn small-btn" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
<div class="content-card card border-0 shadow-sm"> 上一集
<div class="card-body"> </button>
<QishierVideoPlayer
:episode="currentEpisode"
:auto-next="autoNext"
@error="handlePlayerError"
@ended-next="handleEndedNext"
/>
<div v-if="errorMsg" class="alert alert-danger mt-3 mb-0"> <button class="action-btn small-btn" @click="playNextEpisode" :disabled="!hasNextEpisode && !hasMore">
{{ errorMsg }} 下一集
</div> </button>
<QishierShowcase <label class="switch-item">
v-if="showcaseEpisodes.length > 0" <input v-model="autoNext" type="checkbox" />
:episodes="showcaseEpisodes" <span>自动下一集</span>
@select="playEpisode" </label>
/>
</div>
</div>
</div>
<!-- 右侧列表 --> <select v-model="playMode" class="mode-select">
<div class="col-xl-4 col-lg-5"> <option value="asc">顺序</option>
<QishierEpisodeList <option value="desc">倒序</option>
:episodes="displayEpisodeList" </select>
:current-episode-id="currentEpisode?.id || ''" </div>
:years="years"
:months="months" <div class="layout">
:selected-year="selectedYear" <div class="player-panel">
:selected-month="selectedMonth" <QishierVideoPlayer
@select="playEpisode" :episode="currentEpisode"
@updateYear="handleYearChange" :auto-next="autoNext"
@updateMonth="handleMonthChange" @error="handlePlayerError"
@ended-next="handleEndedNext"
/> />
<div v-if="currentEpisode" class="info">
<h2>{{ currentEpisode.title }}</h2>
<p>
发布时间{{ formatDate(currentEpisode.releasedAt) }}
时长{{ formatDuration(currentEpisode.timeLength) }}
</p>
</div>
<div v-if="errorMsg" class="error">
{{ errorMsg }}
</div>
</div> </div>
<QishierEpisodeList
:episodes="displayEpisodeList"
:current-episode-id="currentEpisode?.id || ''"
:has-more="hasMore"
:loading-more="loadingMore"
@select="playEpisode"
@load-more="loadMoreEpisodes"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { getAllQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier' import { getQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier'
import { getCurrentEpisodeId, saveCurrentEpisodeId } from '@/utils/playHistory' import { getCurrentEpisodeId, saveCurrentEpisodeId } from '@/utils/playHistory'
import QishierVideoPlayer from '@/components/qishier/QishierVideoPlayer.vue' import QishierVideoPlayer from '@/components/qishier/QishierVideoPlayer.vue'
import QishierEpisodeList from '@/components/qishier/QishierEpisodeList.vue' import QishierEpisodeList from '@/components/qishier/QishierEpisodeList.vue'
import QishierShowcase from '@/components/qishier/QishierShowcase.vue'
const columnInfo = reactive({ const columnInfo = reactive({
name: '', name: '',
coverUrl: '' coverUrl: ''
}) })
const allEpisodeList = ref<EpisodeData[]>([]) const episodeList = ref<EpisodeData[]>([])
const currentEpisode = ref<EpisodeData | null>(null) const currentEpisode = ref<EpisodeData | null>(null)
const errorMsg = ref('') const errorMsg = ref('')
const updatedAt = ref<string | null>(null) const updatedAt = ref<string | null>(null)
const years = ref<number[]>([])
const selectedYear = ref('all') const total = ref(0)
const months = ref<number[]>([]) const currentPage = ref(1)
const selectedMonth = ref('all') const pageSize = ref(30)
const hasMore = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
const autoNext = ref(true) const autoNext = ref(true)
const playMode = ref<'asc' | 'desc'>('asc') const playMode = ref<'asc' | 'desc'>('asc')
@ -161,28 +102,10 @@ const updatedAtText = computed(() => {
}) })
const displayEpisodeList = computed(() => { const displayEpisodeList = computed(() => {
let list = [...allEpisodeList.value]
if (selectedYear.value !== 'all') {
const year = Number(selectedYear.value)
list = list.filter(item => new Date(item.releasedAt).getFullYear() === year)
}
if (selectedMonth.value !== 'all') {
const month = Number(selectedMonth.value)
list = list.filter(item => new Date(item.releasedAt).getMonth() + 1 === month)
}
if (playMode.value === 'desc') { if (playMode.value === 'desc') {
list.reverse() return [...episodeList.value].reverse()
} }
return episodeList.value
return list
})
const showcaseEpisodes = computed(() => {
const list = displayEpisodeList.value.length > 0 ? displayEpisodeList.value : allEpisodeList.value
return list.slice(0, 8)
}) })
const currentDisplayIndex = computed(() => { const currentDisplayIndex = computed(() => {
@ -199,147 +122,89 @@ function openSource() {
window.open('https://www1.gdtv.cn/', '_blank') window.open('https://www1.gdtv.cn/', '_blank')
} }
function openGitRepo() {
window.open('https://git.a-hxin.cn/ahxin/72QishierPlayer.git', '_blank')
}
function getEpisodeIdFromUrl() {
const path = window.location.pathname
const match = path.match(/^\/episode\/([^/]+)$/)
if (match?.[1]) {
return decodeURIComponent(match[1])
}
const queryId = new URLSearchParams(window.location.search).get('episode')
return queryId || ''
}
function updateUrlByEpisodeId(id: string) {
const nextUrl = `/episode/${encodeURIComponent(id)}`
if (window.location.pathname !== nextUrl) {
window.history.replaceState({}, '', nextUrl)
}
}
function handlePlayerError(message: string) { function handlePlayerError(message: string) {
errorMsg.value = message errorMsg.value = message
} }
function handleYearChange(year: string) { async function fetchFirstPage(): Promise<void> {
selectedYear.value = year
}
function handleMonthChange(month: string) {
selectedMonth.value = month
}
function getEpisodeYear(item: EpisodeData) {
return String(new Date(item.releasedAt).getFullYear())
}
function getEpisodeMonth(item: EpisodeData) {
return String(new Date(item.releasedAt).getMonth() + 1)
}
function rebuildMonthsByYear() {
let source = [...allEpisodeList.value]
if (selectedYear.value !== 'all') {
const year = Number(selectedYear.value)
source = source.filter(item => new Date(item.releasedAt).getFullYear() === year)
}
months.value = [
...new Set(source.map(item => new Date(item.releasedAt).getMonth() + 1))
].sort((a, b) => a - b)
if (selectedMonth.value !== 'all') {
const currentMonth = Number(selectedMonth.value)
if (!months.value.includes(currentMonth)) {
selectedMonth.value = 'all'
}
}
}
async function fetchAllEpisodes() {
try { try {
loading.value = true
errorMsg.value = '' errorMsg.value = ''
const res = await getAllQishierList()
const res = await getQishierList(1, pageSize.value)
const data = res.data const data = res.data
columnInfo.name = data.name || '七十二家房客' columnInfo.name = data.name || '七十二家房客'
columnInfo.coverUrl = data.coverUrl || ''
updatedAt.value = data.updatedAt || null updatedAt.value = data.updatedAt || null
years.value = Array.isArray(data.years) ? [...data.years].sort((a, b) => b - a) : [] total.value = data.total || 0
allEpisodeList.value = Array.isArray(data.list) ? data.list : [] currentPage.value = data.page || 1
hasMore.value = !!data.hasMore
episodeList.value = Array.isArray(data.list) ? data.list : []
if (allEpisodeList.value.length === 0) { if (episodeList.value.length > 0) {
const savedEpisodeId = getCurrentEpisodeId()
const savedEpisode = episodeList.value.find(item => item.id === savedEpisodeId)
const firstEpisode = displayEpisodeList.value[0]
if (savedEpisode) {
await playEpisode(savedEpisode)
} else if (firstEpisode) {
await playEpisode(firstEpisode)
}
} else {
errorMsg.value = '暂无节目数据' errorMsg.value = '暂无节目数据'
return
}
rebuildMonthsByYear()
const urlEpisodeId = getEpisodeIdFromUrl()
const urlEpisode = allEpisodeList.value.find(item => item.id === urlEpisodeId)
if (urlEpisode) {
selectedYear.value = getEpisodeYear(urlEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(urlEpisode)
currentEpisode.value = urlEpisode
saveCurrentEpisodeId(urlEpisode.id)
updateUrlByEpisodeId(urlEpisode.id)
return
}
const savedEpisodeId = getCurrentEpisodeId()
const savedEpisode = allEpisodeList.value.find(item => item.id === savedEpisodeId)
if (savedEpisode) {
selectedYear.value = getEpisodeYear(savedEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(savedEpisode)
currentEpisode.value = savedEpisode
updateUrlByEpisodeId(savedEpisode.id)
return
}
const firstEpisode = allEpisodeList.value[0]
if (firstEpisode) {
selectedYear.value = getEpisodeYear(firstEpisode)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(firstEpisode)
currentEpisode.value = firstEpisode
saveCurrentEpisodeId(firstEpisode.id)
updateUrlByEpisodeId(firstEpisode.id)
} }
} catch (error: any) { } catch (error: any) {
console.error('fetchAllEpisodes error:', error) console.error('fetchFirstPage error:', error)
errorMsg.value = error?.message || '读取数据失败' errorMsg.value = error?.message || '读取数据失败'
} finally {
loading.value = false
} }
} }
async function handleRefresh() { async function loadMoreEpisodes(): Promise<void> {
if (!hasMore.value || loadingMore.value) return
try {
loadingMore.value = true
const nextPage = currentPage.value + 1
const res = await getQishierList(nextPage, pageSize.value)
const data = res.data
const oldIds = new Set(episodeList.value.map(item => item.id))
const newItems = (data.list || []).filter(item => !oldIds.has(item.id))
episodeList.value.push(...newItems)
currentPage.value = data.page || nextPage
hasMore.value = !!data.hasMore
total.value = data.total || total.value
updatedAt.value = data.updatedAt || updatedAt.value
} catch (error: any) {
console.error('loadMoreEpisodes error:', error)
errorMsg.value = error?.message || '加载更多失败'
} finally {
loadingMore.value = false
}
}
async function handleRefresh(): Promise<void> {
try { try {
await refreshQishierCache() await refreshQishierCache()
await fetchAllEpisodes() await fetchFirstPage()
} catch (error: any) { } catch (error: any) {
console.error('handleRefresh error:', error) console.error('handleRefresh error:', error)
errorMsg.value = error?.message || '刷新失败' errorMsg.value = error?.message || '刷新失败'
} }
} }
async function playEpisode(item: EpisodeData) { async function playEpisode(item: EpisodeData): Promise<void> {
currentEpisode.value = item currentEpisode.value = item
selectedYear.value = getEpisodeYear(item)
rebuildMonthsByYear()
selectedMonth.value = getEpisodeMonth(item)
errorMsg.value = '' errorMsg.value = ''
saveCurrentEpisodeId(item.id) saveCurrentEpisodeId(item.id)
updateUrlByEpisodeId(item.id)
} }
async function playPrevEpisode() { async function playPrevEpisode(): Promise<void> {
if (!hasPrevEpisode.value) return if (!hasPrevEpisode.value) return
const prev = displayEpisodeList.value[currentDisplayIndex.value - 1] const prev = displayEpisodeList.value[currentDisplayIndex.value - 1]
if (prev) { if (prev) {
@ -347,18 +212,27 @@ async function playPrevEpisode() {
} }
} }
async function playNextEpisode() { async function playNextEpisode(): Promise<void> {
if (!hasNextEpisode.value) return if (hasNextEpisode.value) {
const next = displayEpisodeList.value[currentDisplayIndex.value + 1] const next = displayEpisodeList.value[currentDisplayIndex.value + 1]
if (next) { if (next) {
await playEpisode(next) await playEpisode(next)
return
}
}
if (hasMore.value) {
const oldLength = displayEpisodeList.value.length
await loadMoreEpisodes()
const next = displayEpisodeList.value[oldLength]
if (next) {
await playEpisode(next)
}
} }
} }
async function handleEndedNext() { async function handleEndedNext() {
if (autoNext.value) { await playNextEpisode()
await playNextEpisode()
}
} }
function formatDate(timestamp: number): string { function formatDate(timestamp: number): string {
@ -377,94 +251,132 @@ function formatDuration(seconds: number): string {
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
} }
watch(selectedYear, () => {
rebuildMonthsByYear()
})
onMounted(() => { onMounted(() => {
void fetchAllEpisodes() void fetchFirstPage()
}) })
</script> </script>
<style scoped> <style scoped>
.qishier-page { .page {
min-height: 100vh; min-height: 100vh;
background: padding: 20px;
radial-gradient(circle at top left, rgba(99, 102, 241, 0.10), transparent 28%), background: #111;
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 24%), color: #fff;
linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%);
} }
.top-card, .header {
.control-card, margin-bottom: 16px;
.content-card { display: flex;
border-radius: 18px; justify-content: space-between;
background: rgba(255, 255, 255, 0.96); align-items: flex-start;
backdrop-filter: blur(10px); gap: 16px;
} }
.page-main-info { .header h1 {
min-width: 0; margin: 0 0 8px;
} }
.page-title { .header p {
font-weight: 800; margin: 0 0 6px;
color: #1f2937; color: #999;
letter-spacing: -0.02em;
line-height: 1.35;
} }
.page-subtitle { .sub {
color: #6b7280; font-size: 13px;
font-size: 14px;
} }
.header-actions { .header-actions {
flex-shrink: 0;
}
.mode-select {
width: 132px;
}
.filter-select {
width: 120px;
}
.episode-badges {
display: flex; display: flex;
gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
} }
.info-badge, .toolbar {
.meta-pill { display: flex;
display: inline-flex; gap: 10px;
align-items: center; align-items: center;
padding: 5px 10px; flex-wrap: wrap;
border-radius: 999px; margin-bottom: 20px;
font-size: 12px;
font-weight: 600;
} }
.info-badge.playing { .switch-item {
background: rgba(59, 130, 246, 0.12); display: flex;
color: #2563eb; align-items: center;
} gap: 6px;
color: #ddd;
.meta-pill {
background: rgba(15, 23, 42, 0.06);
color: #475569;
}
.episode-meta {
color: #6b7280;
font-size: 14px; font-size: 14px;
} }
@media (max-width: 991px) { .mode-select {
.header-actions { padding: 10px 12px;
width: 100%; border-radius: 8px;
border: none;
background: #333;
color: #fff;
}
.action-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #333;
color: #fff;
cursor: pointer;
}
.action-btn:hover {
background: #444;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.small-btn {
padding: 8px 14px;
}
.layout {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 20px;
}
.player-panel {
background: #1b1b1b;
border-radius: 12px;
overflow: hidden;
}
.info {
padding: 16px;
}
.info h2 {
margin: 0 0 10px;
font-size: 20px;
line-height: 1.5;
}
.info p {
margin: 0;
color: #aaa;
font-size: 14px;
}
.error {
padding: 0 16px 16px;
color: #ff7875;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
} }
} }
</style> </style>

View File

@ -17,8 +17,8 @@ export default defineConfig(({ command }) => ({
open: true, open: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:23822/', //target: 'http://127.0.0.1:23822/',
//target: 'http://8.134.120.132:23822/', target: 'http://8.134.120.132:23822/',
changeOrigin: true changeOrigin: true
} }