七十二家房客-观看平台v1.6

This commit is contained in:
云上贵猪 2026-03-12 13:10:11 +08:00
parent c37649db5d
commit 96a7dae310
13 changed files with 954 additions and 364 deletions

View File

@ -1,6 +1,6 @@
{ {
"year": 2024, "year": 2024,
"updatedAt": "2026-03-10T21:35:04.482Z", "updatedAt": "2026-03-12T04:57:57.972Z",
"items": [ "items": [
{ {
"id": "7f012566d7827b5046de9f92a4d7e159", "id": "7f012566d7827b5046de9f92a4d7e159",

View File

@ -1,6 +1,6 @@
{ {
"year": 2025, "year": 2025,
"updatedAt": "2026-03-10T21:34:22.356Z", "updatedAt": "2026-03-12T04:57:16.089Z",
"items": [ "items": [
{ {
"id": "b2b4f80b846360647d13e297c029be8d", "id": "b2b4f80b846360647d13e297c029be8d",

View File

@ -1,6 +1,6 @@
{ {
"year": 2026, "year": 2026,
"updatedAt": "2026-03-10T21:33:31.594Z", "updatedAt": "2026-03-12T04:56:24.101Z",
"items": [ "items": [
{ {
"id": "5abbc88ea530b5db6438a4e584e80281", "id": "5abbc88ea530b5db6438a4e584e80281",

View File

@ -1,5 +1,5 @@
{ {
"running": false, "running": false,
"lastMessage": "自动采集完成", "lastMessage": "自动采集完成",
"updatedAt": "2026-03-10T21:35:26.989Z" "updatedAt": "2026-03-12T05:01:28.586Z"
} }

BIN
dist.zip

Binary file not shown.

View File

@ -13,6 +13,7 @@
"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,25 +9,18 @@ export interface EpisodeData {
videoUrl: string videoUrl: string
} }
export interface QishierListResponse { export interface QishierAllResponse {
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 getQishierList(page = 1, pageSize = 50) { export function getAllQishierList() {
return axios.get<QishierListResponse>('/api/qishier/list', { return axios.get<QishierAllResponse>('/api/qishier/all', {
params: {
page,
pageSize
},
timeout: 15000 timeout: 15000
}) })
} }

View File

@ -1,45 +1,115 @@
<template> <template>
<div ref="listPanelRef" class="list-panel"> <div class="episode-card card border-0 shadow-sm h-100">
<div <div class="card-header bg-transparent border-0 pt-3 pb-2 px-3">
v-for="item in episodes" <div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
:key="item.id" <div class="fw-bold text-dark">剧集列表</div>
:data-episode-id="item.id"
class="episode-item" <select v-model="innerYear" class="form-select form-select-sm year-select">
:class="{ active: currentEpisodeId === item.id }" <option value="all">全部年份</option>
@click="$emit('select', item)" <option v-for="year in years" :key="year" :value="String(year)">
> {{ year }}
<img :src="item.coverUrl" alt="" loading="lazy" /> </option>
<div class="episode-text"> </select>
<div class="title">{{ item.title }}</div>
<div class="meta">{{ formatDate(item.releasedAt) }}</div>
</div> </div>
</div> </div>
<div class="load-more-wrap" v-if="hasMore"> <div ref="listPanelRef" class="card-body pt-2 episode-panel">
<button class="load-more-btn" :disabled="loadingMore" @click="$emit('loadMore')"> <button
{{ loadingMore ? '加载中...' : '加载更多' }} v-for="item in pagedEpisodes"
: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 { nextTick, ref, watch } from 'vue' import { computed, 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
hasMore: boolean years: number[]
loadingMore: boolean selectedYear: string
}>() }>()
defineEmits<{ const emit = defineEmits<{
select: [episode: EpisodeData] select: [episode: EpisodeData]
loadMore: [] updateYear: [year: string]
}>() }>()
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 '--'
@ -50,11 +120,29 @@ function formatDate(timestamp: number): string {
return `${y}-${m}-${day}` return `${y}-${m}-${day}`
} }
async function scrollToActiveEpisode() { function isMobileDevice() {
await nextTick() return window.innerWidth < 992
}
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 || !props.currentEpisodeId) return if (!panel) return
const activeEl = panel.querySelector( const activeEl = panel.querySelector(
`[data-episode-id="${props.currentEpisodeId}"]` `[data-episode-id="${props.currentEpisodeId}"]`
@ -62,24 +150,31 @@ async function scrollToActiveEpisode() {
if (!activeEl) return if (!activeEl) return
const panelRect = panel.getBoundingClientRect() //
const itemRect = activeEl.getBoundingClientRect() if (isMobileDevice()) {
const panelTop = panel.scrollTop
const itemTop = activeEl.offsetTop
const itemHeight = activeEl.offsetHeight
const panelHeight = panel.clientHeight
const isAbove = itemRect.top < panelRect.top panel.scrollTo({
const isBelow = itemRect.bottom > panelRect.bottom top: itemTop - panelHeight / 2 + itemHeight / 2,
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 scrollToActiveEpisode() await locateCurrentEpisode()
}, },
{ immediate: true } { immediate: true }
) )
@ -87,92 +182,62 @@ watch(
watch( watch(
() => props.episodes.length, () => props.episodes.length,
async () => { async () => {
await scrollToActiveEpisode() await locateCurrentEpisode()
}
)
watch(
() => props.selectedYear,
async () => {
await locateCurrentEpisode()
} }
) )
</script> </script>
<style scoped> <style scoped>
.list-panel { .episode-card {
max-height: 80vh; border-radius: 18px;
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-item { .episode-row {
display: flex; border-radius: 14px;
gap: 12px; border: 1px solid rgba(0, 0, 0, 0.06);
padding: 10px; padding: 12px 14px;
border-radius: 10px; transition: all 0.2s ease;
cursor: pointer;
margin-bottom: 10px;
transition: 0.2s;
border: 1px solid transparent;
} }
.episode-item:hover { .episode-row:hover {
background: rgba(255, 255, 255, 0.06); transform: translateY(-1px);
} }
.episode-item.active { .current-row {
background: rgba(255, 193, 7, 0.16); box-shadow: 0 8px 20px rgba(255, 193, 7, 0.22);
border-color: rgba(255, 193, 7, 0.45);
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.12) inset;
} }
.episode-item img { .episode-date {
width: 120px; font-size: 12px;
height: 68px; color: #6c757d;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
} }
.episode-text { .episode-title {
flex: 1; line-height: 1.45;
min-width: 0; font-size: 14px;
}
.title {
font-size: 15px;
line-height: 1.5;
margin-bottom: 8px;
font-weight: 600; font-weight: 600;
color: #fff; white-space: normal;
} }
.episode-item.active .title { .year-select {
color: #ffd666; width: 120px;
} }
.meta { .empty-tip {
font-size: 13px; color: #6c757d;
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

@ -0,0 +1,155 @@
<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

@ -0,0 +1,362 @@
<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,6 +11,8 @@ 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
} }
@ -27,8 +29,18 @@ 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
@ -44,6 +56,16 @@ 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
@ -58,23 +80,56 @@ function buildPlayer(episode: EpisodeData) {
setting: true, setting: true,
playbackRate: true, playbackRate: true,
hotkey: true, hotkey: true,
autoHide: 2000,
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 {
@ -86,7 +141,7 @@ function buildPlayer(episode: EpisodeData) {
art.type = 'm3u8' art.type = 'm3u8'
const restore = () => { const restoreProgress = () => {
const progress = getEpisodeProgress(episode.id) const progress = getEpisodeProgress(episode.id)
if (progress > 0 && art?.video) { if (progress > 0 && art?.video) {
try { try {
@ -95,9 +150,21 @@ function buildPlayer(episode: EpisodeData) {
// ignore // ignore
} }
} }
safeAutoPlay()
} }
art.on('video:loadedmetadata', restore) art.on('video:loadedmetadata', restoreProgress)
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) {
@ -135,7 +202,7 @@ watch(
watch( watch(
() => props.autoNext, () => props.autoNext,
() => { () => {
// ended autoNext //
} }
) )
@ -152,6 +219,8 @@ onBeforeUnmount(() => {
.player-wrap { .player-wrap {
width: 100%; width: 100%;
background: #000; background: #000;
border-radius: 12px;
overflow: hidden;
} }
.artplayer-container { .artplayer-container {
@ -164,4 +233,8 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
:deep(.art-video-player) {
background: #000;
}
</style> </style>

View File

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

View File

@ -1,97 +1,117 @@
<template> <template>
<div class="page"> <div class="container-fluid qishier-page py-3">
<div class="header"> <div class="row g-3">
<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-center gap-3">
<p class="sub">最后更新{{ updatedAtText }}</p> <div>
<h2 class="mb-2 page-title">{{ columnInfo.name || '七十二家房客播放器' }}</h2>
<div class="page-subtitle">
{{ allEpisodeList.length }} 最后更新{{ updatedAtText }}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-dark btn-sm px-3" @click="handleRefresh">刷新数据</button>
<button class="btn btn-warning btn-sm px-3" @click="openSource">数据来源荔枝网</button>
</div>
</div>
</div>
</div> </div>
<div class="header-actions"> <div class="col-12">
<button class="action-btn" @click="handleRefresh">刷新数据</button> <div class="toolbar-card card border-0 shadow-sm">
<button class="action-btn source-btn" @click="openSource"> <div class="card-body d-flex flex-wrap gap-2 align-items-center">
数据来源荔枝网 <button class="btn btn-outline-dark btn-sm" @click="playPrevEpisode" :disabled="!hasPrevEpisode">
</button> 上一集
</button>
<button class="btn btn-outline-dark btn-sm" @click="playNextEpisode" :disabled="!hasNextEpisode">
下一集
</button>
<div class="form-check form-switch ms-2">
<input id="autoNext" v-model="autoNext" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="autoNext">自动下一集</label>
</div>
<select v-model="playMode" class="form-select form-select-sm mode-select">
<option value="asc">顺序播放</option>
<option value="desc">倒序播放</option>
</select>
</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"
/>
<button class="action-btn small-btn" @click="playNextEpisode" :disabled="!hasNextEpisode && !hasMore"> <div v-if="currentEpisode" class="episode-info mt-3">
下一集 <div class="episode-badge mb-2">当前播放</div>
</button> <h4 class="mb-2 episode-title">{{ currentEpisode.title }}</h4>
<div class="episode-meta">
发布时间{{ formatDate(currentEpisode.releasedAt) }}
<span class="mx-2"></span>
时长{{ formatDuration(currentEpisode.timeLength) }}
</div>
</div>
<label class="switch-item"> <div v-if="errorMsg" class="alert alert-danger mt-3 mb-0">
<input v-model="autoNext" type="checkbox" /> {{ errorMsg }}
<span>自动下一集</span> </div>
</label>
<select v-model="playMode" class="mode-select"> <QishierShowcase
<option value="asc">顺序</option> v-if="showcaseEpisodes.length > 0"
<option value="desc">倒序</option> :episodes="showcaseEpisodes"
</select> @select="playEpisode"
</div> />
<div class="layout"> <QishierProjectLinks />
<div class="player-panel"> </div>
<QishierVideoPlayer </div>
:episode="currentEpisode" </div>
:auto-next="autoNext"
@error="handlePlayerError" <div class="col-xl-4 col-lg-5">
@ended-next="handleEndedNext" <QishierEpisodeList
:episodes="displayEpisodeList"
:current-episode-id="currentEpisode?.id || ''"
:years="years"
:selected-year="selectedYear"
@select="playEpisode"
@updateYear="handleYearChange"
/> />
<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 } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { getQishierList, refreshQishierCache, type EpisodeData } from '@/api/qishier' import { getAllQishierList, 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'
import QishierProjectLinks from '@/components/qishier/QishierProjectLinks.vue'
const columnInfo = reactive({ const columnInfo = reactive({
name: '', name: '',
coverUrl: '' coverUrl: ''
}) })
const episodeList = ref<EpisodeData[]>([]) const allEpisodeList = 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 total = ref(0) const selectedYear = ref('all')
const currentPage = ref(1)
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')
@ -102,10 +122,23 @@ const updatedAtText = computed(() => {
}) })
const displayEpisodeList = computed(() => { const displayEpisodeList = computed(() => {
if (playMode.value === 'desc') { let list = [...allEpisodeList.value]
return [...episodeList.value].reverse()
if (selectedYear.value !== 'all') {
const year = Number(selectedYear.value)
list = list.filter(item => new Date(item.releasedAt).getFullYear() === year)
} }
return episodeList.value
if (playMode.value === 'desc') {
list.reverse()
}
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(() => {
@ -126,85 +159,68 @@ function handlePlayerError(message: string) {
errorMsg.value = message errorMsg.value = message
} }
async function fetchFirstPage(): Promise<void> { function handleYearChange(year: string) {
try { selectedYear.value = year
loading.value = true }
errorMsg.value = ''
const res = await getQishierList(1, pageSize.value) function getEpisodeYear(item: EpisodeData) {
return String(new Date(item.releasedAt).getFullYear())
}
async function fetchAllEpisodes() {
try {
errorMsg.value = ''
const res = await getAllQishierList()
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
total.value = data.total || 0 years.value = Array.isArray(data.years) ? [...data.years].sort((a, b) => b - a) : []
currentPage.value = data.page || 1 allEpisodeList.value = Array.isArray(data.list) ? data.list : []
hasMore.value = !!data.hasMore
episodeList.value = Array.isArray(data.list) ? data.list : []
if (episodeList.value.length > 0) { if (allEpisodeList.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
}
const savedEpisodeId = getCurrentEpisodeId()
const savedEpisode = allEpisodeList.value.find(item => item.id === savedEpisodeId)
if (savedEpisode) {
selectedYear.value = getEpisodeYear(savedEpisode)
currentEpisode.value = savedEpisode
return
}
const firstEpisode = allEpisodeList.value[0]
if (firstEpisode) {
selectedYear.value = getEpisodeYear(firstEpisode)
currentEpisode.value = firstEpisode
} }
} catch (error: any) { } catch (error: any) {
console.error('fetchFirstPage error:', error) console.error('fetchAllEpisodes error:', error)
errorMsg.value = error?.message || '读取数据失败' errorMsg.value = error?.message || '读取数据失败'
} finally {
loading.value = false
} }
} }
async function loadMoreEpisodes(): Promise<void> { async function handleRefresh() {
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 fetchFirstPage() await fetchAllEpisodes()
} 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): Promise<void> { async function playEpisode(item: EpisodeData) {
currentEpisode.value = item currentEpisode.value = item
selectedYear.value = getEpisodeYear(item)
errorMsg.value = '' errorMsg.value = ''
saveCurrentEpisodeId(item.id) saveCurrentEpisodeId(item.id)
} }
async function playPrevEpisode(): Promise<void> { async function playPrevEpisode() {
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) {
@ -212,27 +228,18 @@ async function playPrevEpisode(): Promise<void> {
} }
} }
async function playNextEpisode(): Promise<void> { async function playNextEpisode() {
if (hasNextEpisode.value) { if (!hasNextEpisode.value) return
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() {
await playNextEpisode() if (autoNext.value) {
await playNextEpisode()
}
} }
function formatDate(timestamp: number): string { function formatDate(timestamp: number): string {
@ -252,131 +259,62 @@ function formatDuration(seconds: number): string {
} }
onMounted(() => { onMounted(() => {
void fetchFirstPage() void fetchAllEpisodes()
}) })
</script> </script>
<style scoped> <style scoped>
.page { .qishier-page {
min-height: 100vh; min-height: 100vh;
padding: 20px; background:
background: #111; radial-gradient(circle at top left, rgba(255, 193, 7, 0.12), transparent 28%),
color: #fff; radial-gradient(circle at top right, rgba(13, 110, 253, 0.1), transparent 24%),
linear-gradient(180deg, #f8f9fa 0%, #eef1f5 100%);
} }
.header { .top-card,
margin-bottom: 16px; .toolbar-card,
display: flex; .content-card {
justify-content: space-between; border-radius: 18px;
align-items: flex-start; background: rgba(255, 255, 255, 0.92);
gap: 16px; backdrop-filter: blur(8px);
} }
.header h1 { .page-title {
margin: 0 0 8px; font-weight: 700;
color: #212529;
} }
.header p { .page-subtitle {
margin: 0 0 6px; color: #6c757d;
color: #999;
}
.sub {
font-size: 13px;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.switch-item {
display: flex;
align-items: center;
gap: 6px;
color: #ddd;
font-size: 14px; font-size: 14px;
} }
.mode-select { .mode-select {
padding: 10px 12px; width: 140px;
border-radius: 8px;
border: none;
background: #333;
color: #fff;
} }
.action-btn { .episode-info {
padding: 10px 16px; padding: 2px 2px 6px;
border: none;
border-radius: 8px;
background: #333;
color: #fff;
cursor: pointer;
} }
.action-btn:hover { .episode-badge {
background: #444; display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 193, 7, 0.18);
color: #9a6a00;
font-size: 12px;
font-weight: 600;
} }
.action-btn:disabled { .episode-title {
opacity: 0.6; color: #212529;
cursor: not-allowed; font-weight: 700;
} }
.small-btn { .episode-meta {
padding: 8px 14px; color: #6c757d;
}
.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; 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>