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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user