mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +00:00
feat: implement movie player page with HLS and embedded streaming support, navigation, and watch history tracking
This commit is contained in:
+39
-16
@@ -3,6 +3,7 @@ import { ChevronDown, Heart, Play, Plus, Share2, Star } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const requestedSource = computed(() => String(route.query.source || 'ophim'))
|
||||
const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '')
|
||||
const selectedServer = ref(0)
|
||||
const movieInfoOpen = ref(false)
|
||||
const activeTab = ref<'episodes' | 'actors'>('episodes')
|
||||
@@ -16,29 +17,37 @@ const {
|
||||
removeFavorite,
|
||||
saveWatchLater,
|
||||
} = useMovieLibrary()
|
||||
const sourceOptions = [
|
||||
{ label: 'OPhim', value: 'ophim' },
|
||||
{ label: 'NguonC', value: 'nguonc' },
|
||||
{ label: 'KKPhim', value: 'kkphim' },
|
||||
]
|
||||
|
||||
const { data: movie, pending, error } = await useFetch(() => `/api/movies/${route.params.slug}`, {
|
||||
query: computed(() => ({
|
||||
source: requestedSource.value,
|
||||
srcs: requestedSources.value || undefined,
|
||||
})),
|
||||
watch: [requestedSource],
|
||||
watch: [requestedSource, requestedSources],
|
||||
})
|
||||
|
||||
const servers = computed(() => movie.value?.servers ?? [])
|
||||
const activeServer = computed(() => servers.value[selectedServer.value])
|
||||
const activeSource = computed(() => String(movie.value?.source || route.query.source || 'ophim'))
|
||||
const activeSource = computed(() => String(activeServer.value?.source || movie.value?.source || route.query.source || 'ophim'))
|
||||
const activeSourceSlug = computed(() => String(activeServer.value?.sourceSlug || movie.value?.slug || route.params.slug))
|
||||
const activeSourceServerIndex = computed(() => Number(activeServer.value?.sourceServerIndex ?? 0))
|
||||
const sourceOptions = computed(() =>
|
||||
(movie.value?.sources || [{ source: activeSource.value, slug: activeSourceSlug.value }])
|
||||
.map((source: any) => ({
|
||||
label: String(source.source || '').toUpperCase(),
|
||||
value: source.source,
|
||||
slug: source.slug,
|
||||
}))
|
||||
.filter((source: any) => source.value && source.slug),
|
||||
)
|
||||
const actors = computed(() => movie.value?.actors ?? [])
|
||||
const actorSummary = computed(() => actors.value.map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', '))
|
||||
const firstWatchLink = computed(() => ({
|
||||
path: `/xem/${route.params.slug}`,
|
||||
path: `/xem/${activeSourceSlug.value}`,
|
||||
query: {
|
||||
source: activeSource.value,
|
||||
server: selectedServer.value,
|
||||
srcs: requestedSources.value || undefined,
|
||||
server: activeSourceServerIndex.value,
|
||||
ep: 1,
|
||||
},
|
||||
}))
|
||||
@@ -48,7 +57,7 @@ const libraryItem = computed(() => {
|
||||
|
||||
return {
|
||||
source: activeSource.value,
|
||||
slug: String(route.params.slug),
|
||||
slug: activeSourceSlug.value,
|
||||
name: movie.value.name,
|
||||
originName: movie.value.originName,
|
||||
thumb: movie.value.thumb,
|
||||
@@ -59,22 +68,32 @@ const libraryItem = computed(() => {
|
||||
|
||||
function episodeLink(index: number) {
|
||||
return {
|
||||
path: `/xem/${route.params.slug}`,
|
||||
path: `/xem/${activeSourceSlug.value}`,
|
||||
query: {
|
||||
source: activeSource.value,
|
||||
server: selectedServer.value,
|
||||
srcs: requestedSources.value || undefined,
|
||||
server: activeSourceServerIndex.value,
|
||||
ep: index + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLink(source: string) {
|
||||
const ref = sourceOptions.value.find((item: any) => item.value === source)
|
||||
return {
|
||||
path: `/phim/${route.params.slug}`,
|
||||
query: { source },
|
||||
path: `/phim/${ref?.slug || route.params.slug}`,
|
||||
query: {
|
||||
source,
|
||||
srcs: requestedSources.value || undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function serverLabel(server: any, index: number) {
|
||||
const label = String(server?.name || '').replace(/^(ophim|nguonc|kkphim)\s*-\s*/i, '').trim()
|
||||
return label || `Server ${index + 1}`
|
||||
}
|
||||
|
||||
function formatEpisodeName(name: string, index: number) {
|
||||
const label = String(name || index + 1).trim()
|
||||
return /^\d+$/.test(label) ? `Tập ${label}` : label
|
||||
@@ -153,6 +172,10 @@ watch(requestedSource, () => {
|
||||
selectedServer.value = 0
|
||||
})
|
||||
|
||||
watch(requestedSources, () => {
|
||||
selectedServer.value = 0
|
||||
})
|
||||
|
||||
watch([libraryItem, user], () => {
|
||||
refreshFavoriteState()
|
||||
})
|
||||
@@ -278,7 +301,7 @@ useHead(() => ({
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:items-end">
|
||||
<div class="flex items-center justify-center gap-2 sm:justify-end">
|
||||
<div class="hidden">
|
||||
<span class="text-xs font-black text-slate-300">Nguồn API</span>
|
||||
<NuxtLink v-for="source in sourceOptions" :key="source.value" :to="sourceLink(source.value)"
|
||||
class="shrink-0 rounded-md px-4 py-2 text-sm font-black transition"
|
||||
@@ -338,7 +361,7 @@ useHead(() => ({
|
||||
class="shrink-0 rounded px-4 py-2 text-sm font-black"
|
||||
:class="selectedServer === index ? 'bg-emerald-400 text-slate-950' : 'bg-white/10 text-white hover:bg-white/16'"
|
||||
@click="selectedServer = index">
|
||||
{{ server.name }}
|
||||
{{ serverLabel(server, index) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+22
-21
@@ -8,7 +8,6 @@ const page = ref(Math.max(Number(route.query.page || 1), 1))
|
||||
const keyword = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
const debouncedKeyword = ref(keyword.value.trim())
|
||||
const type = ref(typeof route.query.type === 'string' ? route.query.type : 'all')
|
||||
const source = ref(typeof route.query.source === 'string' ? route.query.source : 'all')
|
||||
const sourceOptions = [
|
||||
{ label: 'Tất cả API', value: 'all' },
|
||||
{ label: 'OPhim', value: 'ophim' },
|
||||
@@ -30,17 +29,12 @@ watch(type, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(source, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch([page, debouncedKeyword, type, source], () => {
|
||||
watch([page, debouncedKeyword, type], () => {
|
||||
router.replace({
|
||||
query: {
|
||||
page: page.value > 1 ? page.value : undefined,
|
||||
q: debouncedKeyword.value || undefined,
|
||||
type: type.value !== 'all' ? type.value : undefined,
|
||||
source: source.value !== 'all' ? source.value : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -49,7 +43,6 @@ const { data, pending, error } = await useFetch('/api/movies', {
|
||||
query: computed(() => ({
|
||||
page: page.value,
|
||||
keyword: debouncedKeyword.value || undefined,
|
||||
source: source.value !== 'all' ? source.value : undefined,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -68,6 +61,23 @@ const filteredMovies = computed(() => {
|
||||
const totalPages = computed(() => Number(data.value?.pagination?.totalPages || data.value?.pagination?.total_pages || 0))
|
||||
const sourceStatus = computed(() => data.value?.sources ?? [])
|
||||
|
||||
function movieSourcesQuery(movie: any) {
|
||||
return (movie.sources || [{ source: movie.source, slug: movie.slug }])
|
||||
.filter((source: any) => source.source && source.slug)
|
||||
.map((source: any) => `${source.source}:${source.slug}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function movieLink(movie: any) {
|
||||
return {
|
||||
path: `/phim/${movie.slug}`,
|
||||
query: {
|
||||
source: movie.source,
|
||||
srcs: movieSourcesQuery(movie),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
page.value += 1
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
@@ -132,16 +142,7 @@ useHead({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-2 text-sm font-black text-slate-200">
|
||||
<button v-for="option in sourceOptions" :key="option.value" type="button"
|
||||
class="rounded-full border border-white/10 px-4 py-2 transition"
|
||||
:class="source === option.value ? 'bg-emerald-300 text-slate-950' : 'bg-white/8 hover:bg-white/14 hover:text-white'"
|
||||
@click="source = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceStatus.length" class="mb-8 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
<div v-if="sourceStatus.length" class="hidden">
|
||||
<span v-for="source in sourceStatus" :key="source.name"
|
||||
class="rounded-full border border-white/10 bg-white/8 px-3 py-1">
|
||||
{{ source.name }}: {{ source.ok ? 'sẵn sàng' : 'đang bị chặn' }}
|
||||
@@ -158,7 +159,7 @@ useHead({
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<NuxtLink v-for="movie in filteredMovies" :key="`${movie.source}-${movie.slug}`"
|
||||
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }" class="group">
|
||||
:to="movieLink(movie)" class="group">
|
||||
<div
|
||||
class="relative aspect-2/3 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:-translate-y-1 group-hover:ring-emerald-300/60">
|
||||
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||
@@ -167,8 +168,8 @@ useHead({
|
||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||
<p class="mt-1 truncate text-xs text-emerald-100">{{ movie.episode || movie.year || movie.quality }}</p>
|
||||
</div>
|
||||
<span class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.source }}
|
||||
<span v-if="movie.sources?.length > 1" class="absolute left-2 top-2 rounded bg-emerald-400 px-2 py-1 text-xs font-black text-slate-950">
|
||||
{{ movie.sources.length }} server
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
Reference in New Issue
Block a user