Add HLS.js support and enhance content streaming logic
- Integrated HLS.js version 1.6.15 into the project for improved video streaming capabilities. - Updated the ContentsController to check for HLS manifest availability and fall back to presigned URLs for original files if not found. - Enhanced the VideoPlayer component to handle loading and error states more effectively, improving user experience during streaming. - Refactored content service methods to return detailed streaming information, including HLS and DASH manifest URLs.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
Post,
|
Post,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
|
||||||
import { Subscriptions } from 'src/subscriptions/decorators/subscriptions.decorator';
|
import { Subscriptions } from 'src/subscriptions/decorators/subscriptions.decorator';
|
||||||
@@ -23,10 +24,17 @@ import { ContentsInterceptor } from './interceptor/contents.interceptor';
|
|||||||
import { TokenAuthGuard } from 'src/auth/guards/token.guard';
|
import { TokenAuthGuard } from 'src/auth/guards/token.guard';
|
||||||
import { StreamContentDTO } from './dto/response/stream-content.dto';
|
import { StreamContentDTO } from './dto/response/stream-content.dto';
|
||||||
import { TranscodingCompletedDTO } from './dto/request/transcoding-completed.dto';
|
import { TranscodingCompletedDTO } from './dto/request/transcoding-completed.dto';
|
||||||
|
import { UploadService } from 'src/upload/upload.service';
|
||||||
|
import { getFileRoute, getPublicS3Url } from 'src/common/helper';
|
||||||
|
|
||||||
@Controller('contents')
|
@Controller('contents')
|
||||||
export class ContentsController {
|
export class ContentsController {
|
||||||
constructor(private readonly contentsService: ContentsService) {}
|
private readonly logger = new Logger(ContentsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contentsService: ContentsService,
|
||||||
|
private readonly uploadService: UploadService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(HybridAuthGuard)
|
@UseGuards(HybridAuthGuard)
|
||||||
@@ -64,7 +72,36 @@ export class ContentsController {
|
|||||||
@UseGuards(HybridAuthGuard, SubscriptionsGuard)
|
@UseGuards(HybridAuthGuard, SubscriptionsGuard)
|
||||||
@Subscriptions(['enthusiast', 'film-buff', 'cinephile'])
|
@Subscriptions(['enthusiast', 'film-buff', 'cinephile'])
|
||||||
async stream(@Param('id') id: string) {
|
async stream(@Param('id') id: string) {
|
||||||
return new StreamContentDTO(await this.contentsService.stream(id));
|
const content = await this.contentsService.stream(id);
|
||||||
|
const dto = new StreamContentDTO(content);
|
||||||
|
|
||||||
|
// Check if the HLS manifest actually exists in the public bucket.
|
||||||
|
// In dev (no transcoding pipeline) the raw upload lives in the
|
||||||
|
// private bucket only — fall back to a presigned URL for it.
|
||||||
|
const publicBucket = process.env.S3_PUBLIC_BUCKET_NAME || 'indeedhub-public';
|
||||||
|
const outputKey =
|
||||||
|
content.project.type === 'episodic'
|
||||||
|
? `${getFileRoute(content.file)}${content.id}/transcoded/file.m3u8`
|
||||||
|
: `${getFileRoute(content.file)}transcoded/file.m3u8`;
|
||||||
|
|
||||||
|
const hlsExists = await this.uploadService.objectExists(
|
||||||
|
outputKey,
|
||||||
|
publicBucket,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hlsExists) {
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS not available — serve a presigned URL for the original file
|
||||||
|
this.logger.log(
|
||||||
|
`HLS manifest not found for content ${id}, falling back to raw file`,
|
||||||
|
);
|
||||||
|
const presignedUrl = await this.uploadService.createPresignedUrl({
|
||||||
|
key: content.file,
|
||||||
|
expires: 60 * 60 * 4, // 4 hours
|
||||||
|
});
|
||||||
|
return { ...dto, file: presignedUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/transcoding')
|
@Post(':id/transcoding')
|
||||||
|
|||||||
@@ -353,25 +353,51 @@ export class BTCPayService implements LightningService {
|
|||||||
const bolt11 = await this.resolveLightningAddress(address, sats);
|
const bolt11 = await this.resolveLightningAddress(address, sats);
|
||||||
|
|
||||||
// Pay the BOLT11 via BTCPay's internal Lightning node
|
// Pay the BOLT11 via BTCPay's internal Lightning node
|
||||||
|
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
|
||||||
|
Logger.log(
|
||||||
|
`Paying ${sats} sats to ${address} via ${payUrl}`,
|
||||||
|
'BTCPayService',
|
||||||
|
);
|
||||||
|
|
||||||
const { data } = await axios.post<BTCPayLightningPayResponse>(
|
const { data } = await axios.post<BTCPayLightningPayResponse>(
|
||||||
this.storeUrl('/lightning/BTC/invoices/pay'),
|
payUrl,
|
||||||
{ BOLT11: bolt11 },
|
{ BOLT11: bolt11 },
|
||||||
{ headers: this.headers },
|
{ headers: this.headers },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.result !== 'Ok') {
|
Logger.log(
|
||||||
|
`BTCPay pay response: ${JSON.stringify(data)}`,
|
||||||
|
'BTCPayService',
|
||||||
|
);
|
||||||
|
|
||||||
|
// BTCPay Greenfield API returns different shapes depending on version.
|
||||||
|
// Older versions return { result, errorDetail, paymentHash }.
|
||||||
|
// Newer versions may return the payment hash/preimage at top level
|
||||||
|
// or an empty 200 on success.
|
||||||
|
const result = data?.result;
|
||||||
|
const paymentHash =
|
||||||
|
data?.paymentHash ?? (data as any)?.payment_hash ?? 'btcpay-payment';
|
||||||
|
|
||||||
|
if (result && result !== 'Ok') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Lightning payment failed: ${data.result} — ${data.errorDetail || 'unknown error'}`,
|
`Lightning payment failed: ${result} — ${data?.errorDetail || 'unknown error'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: data.paymentHash || 'btcpay-payment',
|
id: paymentHash,
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('BTCPay sendPayment failed: ' + error.message, 'BTCPayService');
|
const detail =
|
||||||
throw new Error(error.message);
|
error.response?.data
|
||||||
|
? JSON.stringify(error.response.data)
|
||||||
|
: error.message;
|
||||||
|
Logger.error(
|
||||||
|
`BTCPay sendPayment failed: ${detail}`,
|
||||||
|
'BTCPayService',
|
||||||
|
);
|
||||||
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,25 +161,14 @@ export class PaymentService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async handlePaymentsCron() {
|
async handlePaymentsCron() {
|
||||||
if (
|
|
||||||
process.env.ENVIRONMENT === 'development' ||
|
|
||||||
process.env.ENVIRONMENT === 'local'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.handlePayment('watch'),
|
this.handlePayment('watch'),
|
||||||
this.handlePayment('rent'),
|
this.handlePayment('rent'),
|
||||||
]);
|
]);
|
||||||
Logger.log('Payments handled');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_10AM)
|
@Cron(CronExpression.EVERY_DAY_AT_10AM)
|
||||||
async handleDailyPayments() {
|
async handleDailyPayments() {
|
||||||
if (
|
|
||||||
process.env.ENVIRONMENT === 'development' ||
|
|
||||||
process.env.ENVIRONMENT === 'local'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.handlePayment('watch', 'daily'),
|
this.handlePayment('watch', 'daily'),
|
||||||
this.handlePayment('rent', 'daily'),
|
this.handlePayment('rent', 'daily'),
|
||||||
@@ -188,11 +177,6 @@ export class PaymentService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_WEEK)
|
@Cron(CronExpression.EVERY_WEEK)
|
||||||
async handleWeeklyPayments() {
|
async handleWeeklyPayments() {
|
||||||
if (
|
|
||||||
process.env.ENVIRONMENT === 'development' ||
|
|
||||||
process.env.ENVIRONMENT === 'local'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.handlePayment('watch', 'weekly'),
|
this.handlePayment('watch', 'weekly'),
|
||||||
this.handlePayment('rent', 'weekly'),
|
this.handlePayment('rent', 'weekly'),
|
||||||
@@ -201,11 +185,6 @@ export class PaymentService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON)
|
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON)
|
||||||
async handleMonthlyPayments() {
|
async handleMonthlyPayments() {
|
||||||
if (
|
|
||||||
process.env.ENVIRONMENT === 'development' ||
|
|
||||||
process.env.ENVIRONMENT === 'local'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.handlePayment('watch', 'monthly'),
|
this.handlePayment('watch', 'monthly'),
|
||||||
this.handlePayment('rent', 'monthly'),
|
this.handlePayment('rent', 'monthly'),
|
||||||
@@ -218,9 +197,15 @@ export class PaymentService {
|
|||||||
) {
|
) {
|
||||||
const satoshiRate = await this.provider.getSatoshiRate();
|
const satoshiRate = await this.provider.getSatoshiRate();
|
||||||
const column = type === 'watch' ? 'pendingRevenue' : 'rentPendingRevenue';
|
const column = type === 'watch' ? 'pendingRevenue' : 'rentPendingRevenue';
|
||||||
|
|
||||||
|
// Rental prices (and therefore rentPendingRevenue) are denominated in
|
||||||
|
// sats, not USD. Use a 1-sat minimum threshold for rentals instead of
|
||||||
|
// the USD-based satoshiRate.
|
||||||
|
const minThreshold = type === 'rent' ? 1 : satoshiRate;
|
||||||
|
|
||||||
const options: FindManyOptions<Shareholder> = {
|
const options: FindManyOptions<Shareholder> = {
|
||||||
where: {
|
where: {
|
||||||
[column]: MoreThanOrEqual(satoshiRate),
|
[column]: MoreThanOrEqual(minThreshold),
|
||||||
filmmaker: {
|
filmmaker: {
|
||||||
paymentMethods: {
|
paymentMethods: {
|
||||||
type: 'LIGHTNING',
|
type: 'LIGHTNING',
|
||||||
@@ -230,7 +215,7 @@ export class PaymentService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
pendingRevenue: 'DESC',
|
[column]: 'DESC',
|
||||||
},
|
},
|
||||||
relations: ['filmmaker', 'filmmaker.paymentMethods'],
|
relations: ['filmmaker', 'filmmaker.paymentMethods'],
|
||||||
take: 5,
|
take: 5,
|
||||||
@@ -260,18 +245,27 @@ export class PaymentService {
|
|||||||
type === 'watch'
|
type === 'watch'
|
||||||
? shareholder.pendingRevenue
|
? shareholder.pendingRevenue
|
||||||
: shareholder.rentPendingRevenue;
|
: shareholder.rentPendingRevenue;
|
||||||
const sats = revenue / satoshiRate;
|
|
||||||
|
|
||||||
const rounded = Math.floor(sats);
|
// Rental revenue is already in sats (rentalPrice is denominated in sats).
|
||||||
|
// Subscription/watch revenue is in USD and must be converted.
|
||||||
|
let rounded: number;
|
||||||
|
let revenueToBeSent: number;
|
||||||
|
|
||||||
|
if (type === 'rent') {
|
||||||
|
rounded = Math.floor(revenue);
|
||||||
|
revenueToBeSent = rounded; // sats in, sats out
|
||||||
|
} else {
|
||||||
|
const sats = revenue / satoshiRate;
|
||||||
|
rounded = Math.floor(sats);
|
||||||
|
const missing = sats - rounded;
|
||||||
|
const missingRevenue = missing * satoshiRate;
|
||||||
|
revenueToBeSent = revenue - missingRevenue;
|
||||||
|
}
|
||||||
|
|
||||||
if (rounded <= 0) {
|
if (rounded <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = sats - rounded;
|
|
||||||
const missingRevenue = missing * satoshiRate;
|
|
||||||
const revenueToBeSent = revenue - missingRevenue;
|
|
||||||
|
|
||||||
const selectedLightningAddress = shareholder.filmmaker.paymentMethods.find(
|
const selectedLightningAddress = shareholder.filmmaker.paymentMethods.find(
|
||||||
(method) => method.selected && method.type === 'LIGHTNING',
|
(method) => method.selected && method.type === 'LIGHTNING',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ export class RentsService {
|
|||||||
|
|
||||||
async payShareholders(contentId: string, amount: number) {
|
async payShareholders(contentId: string, amount: number) {
|
||||||
const total = amount * REVENUE_PERCENTAGE_TO_PAY;
|
const total = amount * REVENUE_PERCENTAGE_TO_PAY;
|
||||||
await this.shareholdersRepository.update(
|
|
||||||
|
// Try direct content match first
|
||||||
|
const result = await this.shareholdersRepository.update(
|
||||||
{
|
{
|
||||||
contentId,
|
contentId,
|
||||||
deletedAt: IsNull(),
|
deletedAt: IsNull(),
|
||||||
@@ -193,6 +195,57 @@ export class RentsService {
|
|||||||
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${total})`,
|
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${total})`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.affected && result.affected > 0) return;
|
||||||
|
|
||||||
|
// No shareholders found for this specific content — fall back to
|
||||||
|
// project-level lookup. This handles cases where a project has
|
||||||
|
// multiple content records (e.g. re-uploads) but shareholders were
|
||||||
|
// assigned to an earlier content.
|
||||||
|
try {
|
||||||
|
const content = await this.contentsService.findOne(contentId);
|
||||||
|
if (!content?.projectId) return;
|
||||||
|
|
||||||
|
const siblings = await this.shareholdersRepository
|
||||||
|
.createQueryBuilder('sh')
|
||||||
|
.innerJoin('contents', 'c', 'c.id = sh.content_id')
|
||||||
|
.where('c.project_id = :projectId', {
|
||||||
|
projectId: content.projectId,
|
||||||
|
})
|
||||||
|
.andWhere('sh.deleted_at IS NULL')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (siblings.length === 0) {
|
||||||
|
Logger.warn(
|
||||||
|
`No shareholders found for content ${contentId} or its project`,
|
||||||
|
'RentsService',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit revenue to all project-level shareholders
|
||||||
|
const ids = siblings.map((s) => s.id);
|
||||||
|
await this.shareholdersRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({
|
||||||
|
rentPendingRevenue: () =>
|
||||||
|
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${total})`,
|
||||||
|
})
|
||||||
|
.whereInIds(ids)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Credited ${total} USD to ${ids.length} shareholder(s) via project fallback for content ${contentId}`,
|
||||||
|
'RentsService',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to pay shareholders for content ${contentId}: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
'RentsService',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CopyObjectCommand,
|
CopyObjectCommand,
|
||||||
CreateMultipartUploadCommand,
|
CreateMultipartUploadCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
|
GetObjectCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
ListObjectsCommand,
|
ListObjectsCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
@@ -173,9 +174,9 @@ export class UploadService {
|
|||||||
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID;
|
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID;
|
||||||
|
|
||||||
// If CloudFront is not configured (MinIO/self-hosted mode),
|
// If CloudFront is not configured (MinIO/self-hosted mode),
|
||||||
// generate an S3 presigned URL instead
|
// generate an S3 presigned URL that allows GET (streaming/download)
|
||||||
if (!privateKey || !keyPairId) {
|
if (!privateKey || !keyPairId) {
|
||||||
const command = new HeadObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: process.env.S3_PRIVATE_BUCKET_NAME,
|
Bucket: process.env.S3_PRIVATE_BUCKET_NAME,
|
||||||
Key: key,
|
Key: key,
|
||||||
});
|
});
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"applesauce-relay": "^5.1.0",
|
"applesauce-relay": "^5.1.0",
|
||||||
"applesauce-signers": "^5.1.0",
|
"applesauce-signers": "^5.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"nostr-tools": "^2.23.0",
|
"nostr-tools": "^2.23.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -5748,6 +5749,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||||
|
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/hookable": {
|
"node_modules/hookable": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"applesauce-relay": "^5.1.0",
|
"applesauce-relay": "^5.1.0",
|
||||||
"applesauce-signers": "^5.1.0",
|
"applesauce-signers": "^5.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"nostr-tools": "^2.23.0",
|
"nostr-tools": "^2.23.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Transition name="player-fade">
|
<Transition name="player-fade">
|
||||||
<div v-if="isOpen" class="video-player-overlay">
|
<div v-if="isOpen" class="video-player-overlay" ref="playerOverlay">
|
||||||
<div class="video-player-container">
|
<div class="video-player-container" @mousemove="showControls" @mouseleave="hideControlsDelayed">
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button @click="closePlayer" class="close-button">
|
<button @click="closePlayer" class="close-button">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- ── Real YouTube Embed ── -->
|
<!-- ── YouTube / Partner Embed ── -->
|
||||||
<template v-if="embedUrl">
|
<template v-if="embedUrl">
|
||||||
<div class="video-area iframe-area">
|
<div class="video-area iframe-area">
|
||||||
<iframe
|
<iframe
|
||||||
@@ -43,126 +43,169 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ── Demo Player (no streaming URL) ── -->
|
<!-- ── HLS Video Player ── -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="video-area">
|
<!-- Loading state -->
|
||||||
<img
|
<div v-if="isLoadingStream" class="video-area">
|
||||||
v-if="content?.backdrop || content?.thumbnail"
|
<div class="flex flex-col items-center justify-center gap-4">
|
||||||
:src="content?.backdrop || content?.thumbnail"
|
<div class="spinner"></div>
|
||||||
:alt="content?.title"
|
<span class="text-white/60 text-sm">Loading stream...</span>
|
||||||
class="w-full h-full object-cover"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<!-- Play Overlay -->
|
<!-- Error state -->
|
||||||
<div class="video-overlay">
|
<div v-else-if="streamError" class="video-area">
|
||||||
<button class="play-button" @click="togglePlay">
|
<div class="flex flex-col items-center justify-center gap-4 text-center px-8">
|
||||||
<svg v-if="!isPlaying" class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-red-400/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
<p class="text-white/70 text-sm max-w-sm">{{ streamError }}</p>
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
<button @click="fetchStream" class="text-sm text-orange-400 hover:text-orange-300 transition-colors">
|
||||||
</svg>
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Demo Notice -->
|
<!-- Video element -->
|
||||||
<div class="demo-notice">
|
<div v-else class="video-area" @click="togglePlay">
|
||||||
<div class="demo-badge">
|
<video
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
ref="videoEl"
|
||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
class="w-full h-full"
|
||||||
</svg>
|
playsinline
|
||||||
Demo Mode
|
@timeupdate="onTimeUpdate"
|
||||||
</div>
|
@durationchange="onDurationChange"
|
||||||
<p class="text-sm">Video player preview - Full streaming coming soon</p>
|
@play="isPlaying = true"
|
||||||
|
@pause="isPlaying = false"
|
||||||
|
@ended="isPlaying = false"
|
||||||
|
@waiting="isBuffering = true"
|
||||||
|
@canplay="isBuffering = false"
|
||||||
|
@volumechange="onVolumeChange"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Buffering spinner -->
|
||||||
|
<div v-if="isBuffering && isPlaying" class="video-overlay pointer-events-none">
|
||||||
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Large centre play button (visible when paused + controls shown) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="!isPlaying && controlsVisible" class="video-overlay">
|
||||||
|
<button class="play-button" @click.stop="togglePlay">
|
||||||
|
<svg class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Controls -->
|
<!-- Video Controls -->
|
||||||
<div class="video-controls">
|
<Transition name="controls-slide">
|
||||||
<!-- Progress Bar -->
|
<div v-show="controlsVisible || !isPlaying" class="video-controls" @click.stop>
|
||||||
<div class="progress-container">
|
<!-- Progress Bar -->
|
||||||
<div class="progress-bar">
|
<div class="progress-container">
|
||||||
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
|
<div
|
||||||
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
|
class="progress-bar"
|
||||||
</div>
|
ref="progressBarEl"
|
||||||
<div class="time-display">
|
@click="seekTo"
|
||||||
<span>{{ formatTime(currentTime) }}</span>
|
@mousedown="startSeeking"
|
||||||
<span class="text-white/60">/</span>
|
>
|
||||||
<span class="text-white/60">{{ formatTime(duration) }}</span>
|
<div class="progress-buffered" :style="{ width: `${buffered}%` }"></div>
|
||||||
</div>
|
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
|
||||||
</div>
|
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
|
||||||
|
</div>
|
||||||
<!-- Control Buttons -->
|
<div class="time-display">
|
||||||
<div class="control-buttons">
|
<span>{{ formatTime(currentTime) }}</span>
|
||||||
<!-- Left Side -->
|
<span class="text-white/60">/</span>
|
||||||
<div class="flex items-center gap-4">
|
<span class="text-white/60">{{ formatTime(duration) }}</span>
|
||||||
<button @click="togglePlay" class="control-btn">
|
</div>
|
||||||
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="control-btn">
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M5 4v16l7-8z M12 4v16l7-8z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="control-btn">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="text-white font-medium text-sm">{{ content?.title }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Control Buttons -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="control-buttons">
|
||||||
<button class="control-btn">
|
<!-- Left Side -->
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center gap-4">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<button @click.stop="togglePlay" class="control-btn">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path d="M8 5v14l11-7z" />
|
||||||
</button>
|
</svg>
|
||||||
|
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="quality-selector">
|
<!-- Skip back 10s -->
|
||||||
<button class="control-btn">
|
<button @click.stop="skip(-10)" class="control-btn">
|
||||||
{{ quality }}
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Skip forward 10s -->
|
||||||
|
<button @click.stop="skip(10)" class="control-btn">
|
||||||
|
<svg class="w-6 h-6 scale-x-[-1]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Volume -->
|
||||||
|
<button @click.stop="toggleMute" class="control-btn">
|
||||||
|
<svg v-if="volume === 0" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-white font-medium text-sm hidden md:block">{{ content?.title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Playback speed -->
|
||||||
|
<div class="relative" ref="speedMenuRef">
|
||||||
|
<button @click.stop="showSpeedMenu = !showSpeedMenu" class="control-btn text-sm font-semibold">
|
||||||
|
{{ playbackRate === 1 ? '1x' : playbackRate + 'x' }}
|
||||||
|
</button>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showSpeedMenu" class="speed-menu">
|
||||||
|
<button
|
||||||
|
v-for="rate in [0.5, 0.75, 1, 1.25, 1.5, 2]"
|
||||||
|
:key="rate"
|
||||||
|
@click.stop="setPlaybackRate(rate)"
|
||||||
|
class="speed-option"
|
||||||
|
:class="{ 'speed-active': playbackRate === rate }"
|
||||||
|
>
|
||||||
|
{{ rate }}x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="control-btn" @click.stop="toggleFullscreen">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="control-btn" @click="toggleFullscreen">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
|
|
||||||
<!-- Content Info Panel -->
|
<!-- Content Info Panel (fades on hover) -->
|
||||||
<div class="content-info-panel">
|
<Transition name="fade">
|
||||||
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
|
<div v-if="controlsVisible && !isPlaying" class="content-info-panel">
|
||||||
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
|
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
|
||||||
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
|
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
|
||||||
<span v-if="content?.rating">{{ content.rating }}</span>
|
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
|
||||||
<span v-if="content?.duration">{{ content.duration }}min</span>
|
<span v-if="content?.rating">{{ content.rating }}</span>
|
||||||
<span class="text-green-400 flex items-center gap-1">
|
<span v-if="content?.duration">{{ content.duration }}min</span>
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
</div>
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
|
||||||
</svg>
|
|
||||||
Cinephile Access
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
|
</Transition>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +213,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
import { contentService } from '../services/content.service'
|
||||||
import type { Content } from '../types/content'
|
import type { Content } from '../types/content'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -185,76 +230,244 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const isPlaying = ref(false)
|
// ── Refs ────────────────────────────────────────────────────────────
|
||||||
const progress = ref(0)
|
const playerOverlay = ref<HTMLElement | null>(null)
|
||||||
const currentTime = ref(0)
|
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||||
const duration = ref(7200) // 2 hours in seconds
|
const progressBarEl = ref<HTMLElement | null>(null)
|
||||||
const quality = ref('4K')
|
const speedMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
let playInterval: number | null = null
|
// ── Playback state ──────────────────────────────────────────────────
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const isBuffering = ref(false)
|
||||||
|
const progress = ref(0)
|
||||||
|
const buffered = ref(0)
|
||||||
|
const currentTime = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const volume = ref(1)
|
||||||
|
const playbackRate = ref(1)
|
||||||
|
const showSpeedMenu = ref(false)
|
||||||
|
|
||||||
|
// ── Stream loading ──────────────────────────────────────────────────
|
||||||
|
const isLoadingStream = ref(false)
|
||||||
|
const streamError = ref<string | null>(null)
|
||||||
|
const hlsStreamUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── Controls visibility ─────────────────────────────────────────────
|
||||||
|
const controlsVisible = ref(true)
|
||||||
|
let controlsTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// ── HLS instance ────────────────────────────────────────────────────
|
||||||
|
let hls: Hls | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the YouTube embed URL with autoplay when the player opens.
|
* Build the YouTube embed URL for partner/YouTube content.
|
||||||
* Returns null when the content has no streamingUrl.
|
* Returns null when the content has no external streamingUrl.
|
||||||
*/
|
*/
|
||||||
const embedUrl = computed(() => {
|
const embedUrl = computed(() => {
|
||||||
if (!props.content?.streamingUrl) return null
|
if (!props.content?.streamingUrl) return null
|
||||||
const base = props.content.streamingUrl
|
const base = props.content.streamingUrl
|
||||||
|
// Only treat it as an embed if it looks like a YouTube URL
|
||||||
|
if (!base.includes('youtube') && !base.includes('youtu.be')) return null
|
||||||
const sep = base.includes('?') ? '&' : '?'
|
const sep = base.includes('?') ? '&' : '?'
|
||||||
return `${base}${sep}autoplay=1&rel=0&modestbranding=1`
|
return `${base}${sep}autoplay=1&rel=0&modestbranding=1`
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.isOpen, (newVal) => {
|
// ── Lifecycle ───────────────────────────────────────────────────────
|
||||||
if (newVal) {
|
|
||||||
// Reset player state when opened
|
watch(() => props.isOpen, async (open) => {
|
||||||
progress.value = 0
|
if (open) {
|
||||||
currentTime.value = 0
|
controlsVisible.value = true
|
||||||
isPlaying.value = false
|
if (!embedUrl.value) {
|
||||||
|
await fetchStream()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stop playing when closed
|
destroyHls()
|
||||||
stopPlay()
|
resetState()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function closePlayer() {
|
onUnmounted(() => {
|
||||||
stopPlay()
|
destroyHls()
|
||||||
emit('close')
|
if (controlsTimeout) clearTimeout(controlsTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Stream fetching ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchStream() {
|
||||||
|
const contentId = props.content?.contentId || props.content?.id
|
||||||
|
if (!contentId) {
|
||||||
|
streamError.value = 'Content ID not available.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingStream.value = true
|
||||||
|
streamError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await contentService.getStreamInfo(contentId)
|
||||||
|
hlsStreamUrl.value = info.file
|
||||||
|
await nextTick()
|
||||||
|
initPlayer(info.file)
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status === 403) {
|
||||||
|
streamError.value = 'You need an active subscription or rental to watch this content.'
|
||||||
|
} else {
|
||||||
|
streamError.value = 'Unable to load the stream. Please try again.'
|
||||||
|
}
|
||||||
|
console.error('Failed to fetch stream info:', err)
|
||||||
|
} finally {
|
||||||
|
isLoadingStream.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initPlayer(url: string) {
|
||||||
|
destroyHls()
|
||||||
|
const video = videoEl.value
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
const isHls = url.includes('.m3u8')
|
||||||
|
|
||||||
|
if (isHls && Hls.isSupported()) {
|
||||||
|
hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
})
|
||||||
|
hls.loadSource(url)
|
||||||
|
hls.attachMedia(video)
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
video.play().catch(() => {
|
||||||
|
// Autoplay blocked — user needs to click play
|
||||||
|
})
|
||||||
|
})
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
hls?.startLoad()
|
||||||
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
|
hls?.recoverMediaError()
|
||||||
|
} else {
|
||||||
|
streamError.value = 'Playback error. Please try again.'
|
||||||
|
destroyHls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Safari native HLS
|
||||||
|
video.src = url
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
video.play().catch(() => {})
|
||||||
|
}, { once: true })
|
||||||
|
} else if (!isHls) {
|
||||||
|
// Direct video file (mp4, mov, webm) — use native <video>
|
||||||
|
video.src = url
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
video.play().catch(() => {})
|
||||||
|
}, { once: true })
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
streamError.value = 'Unable to play this video format. Please try again.'
|
||||||
|
}, { once: true })
|
||||||
|
} else {
|
||||||
|
streamError.value = 'Your browser does not support HLS video playback.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyHls() {
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy()
|
||||||
|
hls = null
|
||||||
|
}
|
||||||
|
if (videoEl.value) {
|
||||||
|
videoEl.value.pause()
|
||||||
|
videoEl.value.removeAttribute('src')
|
||||||
|
videoEl.value.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
isPlaying.value = false
|
||||||
|
isBuffering.value = false
|
||||||
|
progress.value = 0
|
||||||
|
buffered.value = 0
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
hlsStreamUrl.value = null
|
||||||
|
streamError.value = null
|
||||||
|
isLoadingStream.value = false
|
||||||
|
showSpeedMenu.value = false
|
||||||
|
playbackRate.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (isPlaying.value) {
|
const video = videoEl.value
|
||||||
stopPlay()
|
if (!video) return
|
||||||
|
if (video.paused) {
|
||||||
|
video.play().catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
startPlay()
|
video.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPlay() {
|
function skip(seconds: number) {
|
||||||
isPlaying.value = true
|
const video = videoEl.value
|
||||||
|
if (!video) return
|
||||||
// Simulate playback progress
|
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds))
|
||||||
playInterval = window.setInterval(() => {
|
|
||||||
currentTime.value += 1
|
|
||||||
progress.value = (currentTime.value / duration.value) * 100
|
|
||||||
|
|
||||||
// Loop when finished
|
|
||||||
if (currentTime.value >= duration.value) {
|
|
||||||
currentTime.value = 0
|
|
||||||
progress.value = 0
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPlay() {
|
function toggleMute() {
|
||||||
isPlaying.value = false
|
const video = videoEl.value
|
||||||
if (playInterval) {
|
if (!video) return
|
||||||
clearInterval(playInterval)
|
if (video.volume > 0) {
|
||||||
playInterval = null
|
video.volume = 0
|
||||||
|
} else {
|
||||||
|
video.volume = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPlaybackRate(rate: number) {
|
||||||
|
const video = videoEl.value
|
||||||
|
if (!video) return
|
||||||
|
video.playbackRate = rate
|
||||||
|
playbackRate.value = rate
|
||||||
|
showSpeedMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(e: MouseEvent) {
|
||||||
|
const video = videoEl.value
|
||||||
|
const bar = progressBarEl.value
|
||||||
|
if (!video || !bar) return
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
video.currentTime = pct * video.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSeeking(e: MouseEvent) {
|
||||||
|
const video = videoEl.value
|
||||||
|
const bar = progressBarEl.value
|
||||||
|
if (!video || !bar) return
|
||||||
|
const wasPaused = video.paused
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||||
|
video.currentTime = pct * video.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
if (!wasPaused) video.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
video.pause()
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
const el = document.querySelector('.video-player-overlay')
|
const el = playerOverlay.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
document.exitFullscreen()
|
document.exitFullscreen()
|
||||||
@@ -263,11 +476,68 @@ function toggleFullscreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Video events ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
const video = videoEl.value
|
||||||
|
if (!video || !video.duration) return
|
||||||
|
currentTime.value = video.currentTime
|
||||||
|
progress.value = (video.currentTime / video.duration) * 100
|
||||||
|
updateBuffered()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDurationChange() {
|
||||||
|
if (videoEl.value) {
|
||||||
|
duration.value = videoEl.value.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeChange() {
|
||||||
|
if (videoEl.value) {
|
||||||
|
volume.value = videoEl.value.volume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBuffered() {
|
||||||
|
const video = videoEl.value
|
||||||
|
if (!video || !video.duration) return
|
||||||
|
if (video.buffered.length > 0) {
|
||||||
|
buffered.value = (video.buffered.end(video.buffered.length - 1) / video.duration) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls auto-hide ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showControls() {
|
||||||
|
controlsVisible.value = true
|
||||||
|
if (controlsTimeout) clearTimeout(controlsTimeout)
|
||||||
|
if (isPlaying.value) {
|
||||||
|
controlsTimeout = setTimeout(() => {
|
||||||
|
controlsVisible.value = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideControlsDelayed() {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
controlsTimeout = setTimeout(() => {
|
||||||
|
controlsVisible.value = false
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
destroyHls()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
const s = Math.floor(seconds % 60)
|
const s = Math.floor(seconds % 60)
|
||||||
|
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
@@ -290,6 +560,11 @@ function formatTime(seconds: number): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container:hover {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
@@ -325,6 +600,11 @@ function formatTime(seconds: number): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-area video {
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* YouTube iframe fills the entire area */
|
/* YouTube iframe fills the entire area */
|
||||||
@@ -363,29 +643,24 @@ function formatTime(seconds: number): string {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlays */
|
||||||
.video-overlay {
|
.video-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-area:hover .video-overlay {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.play-button {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -396,47 +671,34 @@ function formatTime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-button:hover {
|
.play-button:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-notice {
|
/* Spinner */
|
||||||
position: absolute;
|
.spinner {
|
||||||
top: 20px;
|
width: 40px;
|
||||||
left: 20px;
|
height: 40px;
|
||||||
background: rgba(247, 147, 26, 0.15);
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
backdrop-filter: blur(24px);
|
border-top-color: #F7931A;
|
||||||
-webkit-backdrop-filter: blur(24px);
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(247, 147, 26, 0.3);
|
animation: spin 0.8s linear infinite;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.demo-badge {
|
@keyframes spin {
|
||||||
display: flex;
|
to { transform: rotate(360deg); }
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: rgba(247, 147, 26, 0.3);
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #F7931A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
.video-controls {
|
.video-controls {
|
||||||
background: rgba(0, 0, 0, 0.85);
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.6) 60%, transparent 100%);
|
||||||
backdrop-filter: blur(24px);
|
padding: 40px 24px 16px;
|
||||||
-webkit-backdrop-filter: blur(24px);
|
position: absolute;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
bottom: 0;
|
||||||
padding: 16px 24px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
@@ -446,10 +708,24 @@ function formatTime(seconds: number): string {
|
|||||||
.progress-bar {
|
.progress-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
transition: height 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-buffered {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-filled {
|
.progress-filled {
|
||||||
@@ -459,24 +735,22 @@ function formatTime(seconds: number): string {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: #F7931A;
|
background: #F7931A;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.1s linear;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-handle {
|
.progress-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%) scale(0);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
opacity: 0;
|
transition: transform 0.15s ease;
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-handle {
|
.progress-bar:hover .progress-handle {
|
||||||
opacity: 1;
|
transform: translate(-50%, -50%) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-display {
|
.time-display {
|
||||||
@@ -512,18 +786,50 @@ function formatTime(seconds: number): string {
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quality-selector {
|
/* Speed menu */
|
||||||
position: relative;
|
.speed-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quality-selector .control-btn {
|
.speed-option {
|
||||||
font-weight: 600;
|
padding: 8px 16px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speed-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-active {
|
||||||
|
color: #F7931A;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content info panel */
|
||||||
.content-info-panel {
|
.content-info-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100px;
|
bottom: 120px;
|
||||||
left: 24px;
|
left: 24px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
@@ -532,12 +838,7 @@ function formatTime(seconds: number): string {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
opacity: 0;
|
z-index: 10;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-player-container:hover .content-info-panel {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
@@ -551,25 +852,44 @@ function formatTime(seconds: number): string {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-slide-enter-active,
|
||||||
|
.controls-slide-leave-active {
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-slide-enter-from,
|
||||||
|
.controls-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer-events-none {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.demo-notice {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-info-panel {
|
.content-info-panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-buttons .text-white {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-info-bar {
|
.stream-info-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-controls {
|
||||||
|
padding: 30px 16px 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ class ContentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get streaming URL for content (requires subscription or rental)
|
* Get streaming URLs for content (requires subscription or rental).
|
||||||
|
* Returns the HLS / DASH / FairPlay manifest URLs from the backend.
|
||||||
*/
|
*/
|
||||||
async getStreamingUrl(contentId: string): Promise<{ url: string; drmToken?: string }> {
|
async getStreamInfo(contentId: string): Promise<{
|
||||||
return apiService.get<{ url: string; drmToken?: string }>(`/contents/${contentId}/stream`)
|
file: string
|
||||||
|
widevine: string
|
||||||
|
fairplay: string
|
||||||
|
}> {
|
||||||
|
return apiService.get(`/contents/${contentId}/stream`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user