Enhance content management and user interaction features

- Introduced a new content source toggle in the profile and app header to switch between IndeeHub and TopDoc films.
- Updated the content fetching logic to dynamically load content based on the selected source.
- Enhanced the seeding process to include a combined catalog of IndeeHub and TopDoc films, ensuring diverse content availability.
- Improved user interaction by preventing duplicate reactions and ensuring a smoother voting experience across comments and content.
- Added support for Amber login (NIP-55) for Android users, integrating it into the existing authentication flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 14:24:52 +00:00
parent ab0560de00
commit 35bc78b890
38 changed files with 1107 additions and 185 deletions

View File

@@ -164,6 +164,16 @@
</svg>
<span>My Library</span>
</button>
<!-- Content Source Toggle -->
<button @click="handleSourceToggle" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<span class="flex-1">{{ contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films' }}</span>
<span class="text-[10px] text-white/40 uppercase tracking-wider">Switch</span>
</button>
<div class="border-t border-white/10 my-1"></div>
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -215,6 +225,8 @@ import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { accountManager } from '../lib/accounts'
import { useContentDiscovery } from '../composables/useContentDiscovery'
import { useContentSourceStore } from '../stores/contentSource'
import { useContentStore } from '../stores/content'
type Persona = { name: string; nsec: string; pubkey: string }
@@ -257,6 +269,15 @@ const {
setAlgorithm: _setAlgorithm,
} = useContentDiscovery()
const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore()
/** Toggle between IndeeHub and TopDocFilms catalogs, then reload content */
function handleSourceToggle() {
contentSourceStore.toggle()
contentStore.fetchContent()
}
const dropdownOpen = ref(false)
const personaMenuOpen = ref(false)
const algosMenuOpen = ref(false)

View File

@@ -91,7 +91,7 @@
</div>
</div>
<!-- Nostr Login Button -->
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
<button
@click="handleNostrLogin"
:disabled="isLoading"
@@ -100,7 +100,21 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Sign in with Nostr
Sign in with Nostr Extension
</button>
<!-- Amber Login Button (NIP-55 Android Signer) -->
<button
@click="handleAmberLogin"
:disabled="isLoading"
class="amber-login-button w-full flex items-center justify-center gap-2 mt-3"
>
<!-- Amber shield icon -->
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.2" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12 8v4m0 0v4m0-4h4m-4 0H8" stroke="#F7931A" stroke-width="2" stroke-linecap="round"/>
</svg>
Sign in with Amber
</button>
<!-- Toggle Mode -->
@@ -141,7 +155,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
const { loginWithExtension } = useAccounts()
const { loginWithExtension, loginWithAmber, isAmberSupported } = useAccounts()
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
const formData = ref({
@@ -226,6 +240,47 @@ async function handleNostrLogin() {
}
}
/**
* Login with Amber (NIP-55 Android Signer)
* Uses the AmberClipboardSigner from applesauce-signers which
* handles the Android intent flow and clipboard-based result reading.
* Amber copies the pubkey to clipboard, and when the user returns
* to the browser the signer reads it automatically.
*/
async function handleAmberLogin() {
errorMessage.value = null
try {
// Get the pubkey from Amber (opens intent, reads clipboard on return)
const pubkey = await loginWithAmber()
// Create auth session with the pubkey from Amber
await loginWithNostr(pubkey, 'amber-nip55', {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin],
['method', 'POST'],
],
content: '',
pubkey,
})
emit('success')
closeModal()
} catch (error: any) {
console.error('Amber login failed:', error)
if (error.message?.includes('non-Android')) {
errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.'
} else if (error.message?.includes('clipboard') || error.message?.includes('Empty')) {
errorMessage.value = 'Could not read from clipboard. Please ensure Amber is installed and clipboard permissions are granted.'
} else {
errorMessage.value = error.message || 'Amber login failed. Please try again.'
}
}
}
// Declare window.nostr for TypeScript
declare global {
interface Window {
@@ -312,4 +367,28 @@ declare global {
transform: scale(0.95);
opacity: 0;
}
/* Amber Login Button */
.amber-login-button {
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
color: #F7931A;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.25);
cursor: pointer;
transition: all 0.3s ease;
}
.amber-login-button:hover {
background: rgba(247, 147, 26, 0.15);
border-color: rgba(247, 147, 26, 0.4);
box-shadow: 0 0 20px rgba(247, 147, 26, 0.1);
}
.amber-login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -18,11 +18,11 @@
<!-- Actions -->
<div class="flex items-center gap-3 mt-2">
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : hasVoted ? 0.4 : 1 }">
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted || isReacting" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : (hasVoted || isReacting) ? 0.4 : 1 }">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
<span v-if="reactionCounts.positive > 0">{{ reactionCounts.positive }}</span>
</button>
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : hasVoted ? 0.4 : 1 }">
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted || isReacting" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : (hasVoted || isReacting) ? 0.4 : 1 }">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
<span v-if="reactionCounts.negative > 0">{{ reactionCounts.negative }}</span>
</button>
@@ -58,11 +58,11 @@
<!-- Reply actions (compact) -->
<div class="flex items-center gap-2 mt-1 ml-1">
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : hasVoted ? 0.35 : 1 }">
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted || isReacting" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : (hasVoted || isReacting) ? 0.35 : 1 }">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
<span v-if="reactionCounts.positive > 0">{{ reactionCounts.positive }}</span>
</button>
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : hasVoted ? 0.35 : 1 }">
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted || isReacting" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : (hasVoted || isReacting) ? 0.35 : 1 }">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
<span v-if="reactionCounts.negative > 0">{{ reactionCounts.negative }}</span>
</button>

View File

@@ -58,10 +58,10 @@
<!-- Like Button -->
<button
@click="handleLike"
:disabled="hasVoted || !isNostrLoggedIn"
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '+' }"
:style="{ opacity: userReaction === '+' ? 1 : hasVoted ? 0.4 : 1 }"
:style="{ opacity: userReaction === '+' ? 1 : (isNostrLoggedIn && (hasVoted || isReacting)) ? 0.4 : 1 }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
@@ -72,10 +72,10 @@
<!-- Dislike Button -->
<button
@click="handleDislike"
:disabled="hasVoted || !isNostrLoggedIn"
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '-' }"
:style="{ opacity: userReaction === '-' ? 1 : hasVoted ? 0.4 : 1 }"
:style="{ opacity: userReaction === '-' ? 1 : (isNostrLoggedIn && (hasVoted || isReacting)) ? 0.4 : 1 }"
>
<svg class="w-5 h-5 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
@@ -241,13 +241,14 @@ interface Emits {
}
const props = defineProps<Props>()
defineEmits<Emits>()
const emit = defineEmits<Emits>()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn, activePubkey } = useAccounts()
const newComment = ref('')
const isPostingComment = ref(false)
const isReacting = ref(false)
const isInMyList = ref(false)
const showVideoPlayer = ref(false)
const showSubscriptionModal = ref(false)
@@ -296,7 +297,16 @@ function getProfile(pubkey: string) {
}
function handlePlay() {
if (!isAuthenticated.value) return
// Free content with a streaming URL can play without auth
if (props.content?.streamingUrl) {
showVideoPlayer.value = true
return
}
if (!isAuthenticated.value && !isNostrLoggedIn.value) {
emit('openAuth')
return
}
if (hasActiveSubscription.value) {
showVideoPlayer.value = true
@@ -307,27 +317,45 @@ function handlePlay() {
}
function toggleMyList() {
if (!isAuthenticated.value && !isNostrLoggedIn.value) {
emit('openAuth')
return
}
isInMyList.value = !isInMyList.value
}
async function handleLike() {
if (!isNostrLoggedIn.value || hasVoted.value) return
if (!isNostrLoggedIn.value) {
emit('openAuth')
return
}
if (hasVoted.value || isReacting.value) return
if (props.content?.id) {
isReacting.value = true
try {
await nostr.postReaction(true, props.content.id)
} catch (err) {
console.error('Failed to post reaction:', err)
} finally {
isReacting.value = false
}
}
}
async function handleDislike() {
if (!isNostrLoggedIn.value || hasVoted.value) return
if (!isNostrLoggedIn.value) {
emit('openAuth')
return
}
if (hasVoted.value || isReacting.value) return
if (props.content?.id) {
isReacting.value = true
try {
await nostr.postReaction(false, props.content.id)
} catch (err) {
console.error('Failed to post reaction:', err)
} finally {
isReacting.value = false
}
}
}

View File

@@ -47,9 +47,8 @@
<span class="text-xs font-medium whitespace-nowrap">Films</span>
</button>
<!-- Algos (visible on Films and My List) -->
<!-- Algos -->
<button
v-if="isOnFilmsPage || isActive('/library')"
@click="showFilterSheet = true"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
@@ -75,7 +74,7 @@
<!-- Profile -->
<button
@click="navigate('/profile')"
@click="handleProfileClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/profile') }"
>
@@ -148,6 +147,18 @@ function handleMyListClick() {
navigate('/library')
}
/**
* Navigate to Profile if logged in, otherwise open the auth modal
* with a redirect so the user lands on Profile after login.
*/
function handleProfileClick() {
if (!isLoggedInAnywhere.value) {
emit('openAuth', '/profile')
return
}
navigate('/profile')
}
function selectAlgorithm(algo: AlgorithmId) {
setAlgorithm(algo)
showFilterSheet.value = false
@@ -176,55 +187,47 @@ function selectAlgorithm(algo: AlgorithmId) {
}
.nav-tab {
color: rgba(255, 255, 255, 0.7);
position: relative;
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
transition: all 0.3s ease;
padding: 8px 12px;
border-radius: 12px;
border-radius: 14px;
background: transparent;
border: none;
cursor: pointer;
font-weight: 600;
font-weight: 500;
max-width: 80px;
}
.nav-tab:active {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
}
.nav-tab-active {
position: relative;
padding: 8px 12px;
font-weight: 600;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 1);
font-weight: 600;
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
0 8px 20px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: none;
cursor: pointer;
transition: all 0.3s ease;
max-width: 80px;
}
/* Subtle variant with darker grey border for tab bar */
.nav-tab-active::before {
content: '';
position: absolute;
inset: -2px;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
padding: 1.5px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.25), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: -1;
}
/* --- Filter Bottom Sheet --- */

View File

@@ -9,132 +9,168 @@
</svg>
</button>
<!-- Video Area (Dummy Player) -->
<div class="video-area">
<img
v-if="content?.backdrop || content?.thumbnail"
:src="content?.backdrop || content?.thumbnail"
:alt="content?.title"
class="w-full h-full object-cover"
/>
<!-- Play Overlay -->
<div class="video-overlay">
<button class="play-button" @click="togglePlay">
<svg v-if="!isPlaying" class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<svg v-else class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</button>
<!-- Real YouTube Embed -->
<template v-if="embedUrl">
<div class="video-area iframe-area">
<iframe
:src="embedUrl"
class="w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
<!-- Dummy Notice -->
<div class="demo-notice">
<div class="demo-badge">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<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" />
<!-- Minimal info bar -->
<div class="stream-info-bar">
<div class="flex items-center gap-4 text-sm">
<span class="text-white font-medium">{{ content?.title }}</span>
<span v-if="content?.releaseYear" class="text-white/50">{{ content.releaseYear }}</span>
<span v-if="content?.duration" class="text-white/50">{{ content.duration }} min</span>
</div>
<a
:href="content?.streamingUrl?.replace('/embed/', '/watch?v=') ?? '#'"
target="_blank"
rel="noopener noreferrer"
class="yt-link"
>
Open on YouTube
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Demo Mode
</div>
<p class="text-sm">Video player preview - Full streaming coming soon</p>
</a>
</div>
</div>
</template>
<!-- Video Controls -->
<div class="video-controls">
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
</div>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="text-white/60">/</span>
<span class="text-white/60">{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Control Buttons -->
<div class="control-buttons">
<!-- Left Side -->
<div class="flex items-center gap-4">
<button @click="togglePlay" class="control-btn">
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<!-- Demo Player (no streaming URL) -->
<template v-else>
<div class="video-area">
<img
v-if="content?.backdrop || content?.thumbnail"
:src="content?.backdrop || content?.thumbnail"
:alt="content?.title"
class="w-full h-full object-cover"
/>
<!-- Play Overlay -->
<div class="video-overlay">
<button class="play-button" @click="togglePlay">
<svg v-if="!isPlaying" class="w-20 h-20" 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">
<svg v-else class="w-20 h-20" 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>
<!-- Right Side -->
<div class="flex items-center gap-4">
<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="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" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<!-- Demo Notice -->
<div class="demo-notice">
<div class="demo-badge">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
</button>
Demo Mode
</div>
<p class="text-sm">Video player preview - Full streaming coming soon</p>
</div>
</div>
<!-- Video Controls -->
<div class="video-controls">
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
</div>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="text-white/60">/</span>
<span class="text-white/60">{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Control Buttons -->
<div class="control-buttons">
<!-- Left Side -->
<div class="flex items-center gap-4">
<button @click="togglePlay" class="control-btn">
<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>
<div class="quality-selector">
<button class="control-btn">
{{ quality }}
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<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>
<!-- Right Side -->
<div class="flex items-center gap-4">
<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="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" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div class="quality-selector">
<button class="control-btn">
{{ quality }}
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</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>
<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>
<!-- Content Info Panel -->
<div class="content-info-panel">
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
<span v-if="content?.rating">{{ content.rating }}</span>
<span v-if="content?.duration">{{ content.duration }}min</span>
<span class="text-green-400 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
Cinephile Access
</span>
<!-- Content Info Panel -->
<div class="content-info-panel">
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
<span v-if="content?.rating">{{ content.rating }}</span>
<span v-if="content?.duration">{{ content.duration }}min</span>
<span class="text-green-400 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
Cinephile Access
</span>
</div>
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
</div>
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
</div>
</template>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import type { Content } from '../types/content'
interface Props {
@@ -157,6 +193,17 @@ const quality = ref('4K')
let playInterval: number | null = null
/**
* Build the YouTube embed URL with autoplay when the player opens.
* Returns null when the content has no streamingUrl.
*/
const embedUrl = computed(() => {
if (!props.content?.streamingUrl) return null
const base = props.content.streamingUrl
const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}autoplay=1&rel=0&modestbranding=1`
})
watch(() => props.isOpen, (newVal) => {
if (newVal) {
// Reset player state when opened
@@ -184,7 +231,6 @@ function togglePlay() {
function startPlay() {
isPlaying.value = true
console.log('▶️ Video playing (demo mode)')
// Simulate playback progress
playInterval = window.setInterval(() => {
@@ -208,7 +254,13 @@ function stopPlay() {
}
function toggleFullscreen() {
console.log('🖥️ Fullscreen toggled (demo)')
const el = document.querySelector('.video-player-overlay')
if (!el) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
el.requestFullscreen()
}
}
function formatTime(seconds: number): string {
@@ -275,6 +327,42 @@ function formatTime(seconds: number): string {
justify-content: center;
}
/* YouTube iframe fills the entire area */
.iframe-area iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
/* Minimal info bar for YouTube embed mode */
.stream-info-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.yt-link {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
}
.yt-link:hover {
color: white;
}
.video-overlay {
position: absolute;
inset: 0;
@@ -477,5 +565,11 @@ function formatTime(seconds: number): string {
.control-buttons .text-white {
display: none;
}
.stream-info-bar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
}
</style>