Compare commits
No commits in common. "1ae040cdd33f68f4c508aa9eab367b9f908ee6b8" and "a7c4aa56088222fc6d7bb5eca4a3d94427e2ac56" have entirely different histories.
1ae040cdd3
...
a7c4aa5608
@ -1,3 +1,2 @@
|
|||||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
VITE_API_URL=http://localhost:3001
|
|
||||||
|
|||||||
@ -6,8 +6,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.arthurbarre.fr
|
REGISTRY: git.arthurbarre.fr
|
||||||
IMAGE_FRONT: ordinarthur/wetalk
|
IMAGE: ordinarthur/wetalk
|
||||||
IMAGE_API: ordinarthur/wetalk-api
|
|
||||||
NAMESPACE: wetalk
|
NAMESPACE: wetalk
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@ -24,27 +23,17 @@ jobs:
|
|||||||
username: ordinarthur
|
username: ordinarthur
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push frontend
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:${{ github.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
VITE_API_URL=
|
|
||||||
|
|
||||||
- name: Build and push API
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./api
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_API }}:latest
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_API }}:${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Install kubectl
|
- name: Install kubectl
|
||||||
run: |
|
run: |
|
||||||
@ -68,21 +57,13 @@ jobs:
|
|||||||
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
# Create API secrets
|
|
||||||
kubectl -n $NAMESPACE create secret generic wetalk-api-secrets \
|
|
||||||
--from-literal=SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }} \
|
|
||||||
--from-literal=SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }} \
|
|
||||||
--from-literal=YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }} \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
|
||||||
# Apply manifests
|
# Apply manifests
|
||||||
kubectl apply -f k8s/service.yml
|
kubectl apply -f k8s/service.yml
|
||||||
kubectl apply -f k8s/deployment.yml
|
kubectl apply -f k8s/deployment.yml
|
||||||
|
|
||||||
# Force rollout with new images
|
# Force rollout with new image
|
||||||
kubectl -n $NAMESPACE set image deployment/wetalk \
|
kubectl -n $NAMESPACE set image deployment/wetalk \
|
||||||
wetalk=$REGISTRY/$IMAGE_FRONT:${{ github.sha }} \
|
wetalk=$REGISTRY/$IMAGE:${{ github.sha }}
|
||||||
wetalk-api=$REGISTRY/$IMAGE_API:${{ github.sha }}
|
|
||||||
|
|
||||||
# Wait for rollout
|
# Wait for rollout
|
||||||
kubectl -n $NAMESPACE rollout status deployment/wetalk --timeout=120s
|
kubectl -n $NAMESPACE rollout status deployment/wetalk --timeout=120s
|
||||||
|
|||||||
@ -5,7 +5,6 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_SUPABASE_URL
|
ARG VITE_SUPABASE_URL
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
ARG VITE_API_URL
|
|
||||||
RUN ./node_modules/.bin/vite build
|
RUN ./node_modules/.bin/vite build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.env
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
SPOTIFY_CLIENT_ID=
|
|
||||||
SPOTIFY_CLIENT_SECRET=
|
|
||||||
YOUTUBE_API_KEY=
|
|
||||||
PORT=3001
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
FROM oven/bun:1-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json bun.lock* ./
|
|
||||||
RUN bun install --frozen-lockfile || bun install
|
|
||||||
COPY . .
|
|
||||||
EXPOSE 3001
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost:3001/health || exit 1
|
|
||||||
CMD ["bun", "run", "src/index.ts"]
|
|
||||||
15
api/bun.lock
15
api/bun.lock
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "wetalk-api",
|
|
||||||
"dependencies": {
|
|
||||||
"hono": "^4.7.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "wetalk-api",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "bun run --hot src/index.ts",
|
|
||||||
"start": "bun run src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"hono": "^4.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
234
api/src/index.ts
234
api/src/index.ts
@ -1,234 +0,0 @@
|
|||||||
import { Hono } from 'hono'
|
|
||||||
import { cors } from 'hono/cors'
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
|
|
||||||
app.use('*', cors({
|
|
||||||
origin: '*',
|
|
||||||
allowMethods: ['GET', 'POST'],
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Spotify token cache ──
|
|
||||||
let spotifyToken: string | null = null
|
|
||||||
let spotifyTokenExpiry = 0
|
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID || ''
|
|
||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET || ''
|
|
||||||
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || ''
|
|
||||||
|
|
||||||
async function getSpotifyToken(): Promise<string | null> {
|
|
||||||
if (spotifyToken && Date.now() < spotifyTokenExpiry) return spotifyToken
|
|
||||||
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return null
|
|
||||||
|
|
||||||
const res = await fetch('https://accounts.spotify.com/api/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
|
|
||||||
},
|
|
||||||
body: 'grant_type=client_credentials',
|
|
||||||
})
|
|
||||||
if (!res.ok) return null
|
|
||||||
const data = await res.json()
|
|
||||||
spotifyToken = data.access_token
|
|
||||||
spotifyTokenExpiry = Date.now() + (data.expires_in - 60) * 1000
|
|
||||||
return spotifyToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── URL parsing helpers ──
|
|
||||||
function parseDuration(iso: string): number {
|
|
||||||
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
|
|
||||||
if (!match) return 0
|
|
||||||
return parseInt(match[1] || '0') * 3600 + parseInt(match[2] || '0') * 60 + parseInt(match[3] || '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractInfo(url: string): { platform: string; id: string; type?: string } | null {
|
|
||||||
// YouTube
|
|
||||||
const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
|
|
||||||
if (ytMatch) return { platform: 'youtube', id: ytMatch[1] }
|
|
||||||
|
|
||||||
// Dailymotion
|
|
||||||
const dmMatch = url.match(/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/)
|
|
||||||
if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] }
|
|
||||||
|
|
||||||
// SoundCloud
|
|
||||||
if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url }
|
|
||||||
|
|
||||||
// Spotify
|
|
||||||
const spotifyMatch = url.match(/open\.spotify\.com\/(episode|show|track)\/([a-zA-Z0-9]+)/)
|
|
||||||
if (spotifyMatch) return { platform: 'spotify', id: spotifyMatch[2], type: spotifyMatch[1] }
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main endpoint ──
|
|
||||||
app.get('/api/metadata', async (c) => {
|
|
||||||
const url = c.req.query('url')
|
|
||||||
if (!url) return c.json({ error: 'Missing url parameter' }, 400)
|
|
||||||
|
|
||||||
const info = extractInfo(url)
|
|
||||||
if (!info) return c.json({ error: 'Unsupported URL' }, 400)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ── YouTube ──
|
|
||||||
if (info.platform === 'youtube') {
|
|
||||||
if (YOUTUBE_API_KEY) {
|
|
||||||
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${info.id}&key=${YOUTUBE_API_KEY}`
|
|
||||||
const res = await fetch(apiUrl)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const video = data.items?.[0]
|
|
||||||
if (video) {
|
|
||||||
const s = video.snippet || {}
|
|
||||||
const thumbs = s.thumbnails || {}
|
|
||||||
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
|
|
||||||
return c.json({
|
|
||||||
title: s.title || '',
|
|
||||||
description: (s.description || '').slice(0, 500),
|
|
||||||
thumbnail: thumb,
|
|
||||||
duration: parseDuration(video.contentDetails?.duration || ''),
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'YouTube',
|
|
||||||
author: s.channelTitle || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback oEmbed
|
|
||||||
const res = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
return c.json({
|
|
||||||
title: data.title || '',
|
|
||||||
description: data.author_name ? `Par ${data.author_name}` : '',
|
|
||||||
thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`,
|
|
||||||
duration: 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'YouTube',
|
|
||||||
author: data.author_name || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Spotify ──
|
|
||||||
if (info.platform === 'spotify') {
|
|
||||||
const token = await getSpotifyToken()
|
|
||||||
if (token) {
|
|
||||||
const contentType = info.type || 'episode'
|
|
||||||
const endpoint = contentType === 'show'
|
|
||||||
? `https://api.spotify.com/v1/shows/${info.id}?market=FR`
|
|
||||||
: contentType === 'track'
|
|
||||||
? `https://api.spotify.com/v1/tracks/${info.id}?market=FR`
|
|
||||||
: `https://api.spotify.com/v1/episodes/${info.id}?market=FR`
|
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
if (contentType === 'episode') {
|
|
||||||
const images = data.images || data.show?.images || []
|
|
||||||
return c.json({
|
|
||||||
title: data.name || '',
|
|
||||||
description: data.description || '',
|
|
||||||
thumbnail: images[0]?.url || '',
|
|
||||||
duration: Math.floor((data.duration_ms || 0) / 1000),
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Spotify',
|
|
||||||
author: data.show?.name || data.show?.publisher || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (contentType === 'show') {
|
|
||||||
return c.json({
|
|
||||||
title: data.name || '',
|
|
||||||
description: data.description || '',
|
|
||||||
thumbnail: (data.images || [])[0]?.url || '',
|
|
||||||
duration: 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Spotify',
|
|
||||||
author: data.publisher || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (contentType === 'track') {
|
|
||||||
const artist = data.artists?.map((a: any) => a.name).join(', ') || ''
|
|
||||||
return c.json({
|
|
||||||
title: data.name || '',
|
|
||||||
description: artist ? `Par ${artist}` : '',
|
|
||||||
thumbnail: (data.album?.images || [])[0]?.url || '',
|
|
||||||
duration: Math.floor((data.duration_ms || 0) / 1000),
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Spotify',
|
|
||||||
author: artist,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback oEmbed
|
|
||||||
const res = await fetch(`https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const parts = (data.title || '').split(' - ')
|
|
||||||
return c.json({
|
|
||||||
title: parts[0]?.trim() || data.title || '',
|
|
||||||
description: '',
|
|
||||||
thumbnail: data.thumbnail_url || '',
|
|
||||||
duration: 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Spotify',
|
|
||||||
author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dailymotion ──
|
|
||||||
if (info.platform === 'dailymotion') {
|
|
||||||
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname`
|
|
||||||
const res = await fetch(apiUrl)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const owner = data.owner?.screenname || ''
|
|
||||||
return c.json({
|
|
||||||
title: data.title || '',
|
|
||||||
description: data.description || '',
|
|
||||||
thumbnail: data.thumbnail_720_url || '',
|
|
||||||
duration: data.duration || 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'Dailymotion',
|
|
||||||
author: owner,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SoundCloud ──
|
|
||||||
if (info.platform === 'soundcloud') {
|
|
||||||
const res = await fetch(`https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
return c.json({
|
|
||||||
title: data.title || '',
|
|
||||||
description: data.description || '',
|
|
||||||
thumbnail: data.thumbnail_url || '',
|
|
||||||
duration: 0,
|
|
||||||
audioUrl: url,
|
|
||||||
platform: 'SoundCloud',
|
|
||||||
author: data.author_name || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ error: 'Could not fetch metadata' }, 404)
|
|
||||||
} catch (e) {
|
|
||||||
return c.json({ error: 'Internal error' }, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/health', (c) => c.json({ ok: true }))
|
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT || '3001')
|
|
||||||
console.log(`[wetalk-api] listening on :${port}`)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port,
|
|
||||||
fetch: app.fetch,
|
|
||||||
}
|
|
||||||
@ -12,15 +12,3 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
|
||||||
api:
|
|
||||||
image: oven/bun:1
|
|
||||||
working_dir: /app
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
volumes:
|
|
||||||
- ./api:/app
|
|
||||||
- /app/node_modules
|
|
||||||
env_file:
|
|
||||||
- ./api/.env
|
|
||||||
command: bun run src/index.ts
|
|
||||||
|
|||||||
@ -37,48 +37,5 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
cpu: "100m"
|
cpu: "100m"
|
||||||
|
|
||||||
- name: wetalk-api
|
|
||||||
image: git.arthurbarre.fr/ordinarthur/wetalk-api:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 3001
|
|
||||||
env:
|
|
||||||
- name: SPOTIFY_CLIENT_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: wetalk-api-secrets
|
|
||||||
key: SPOTIFY_CLIENT_ID
|
|
||||||
- name: SPOTIFY_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: wetalk-api-secrets
|
|
||||||
key: SPOTIFY_CLIENT_SECRET
|
|
||||||
- name: YOUTUBE_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: wetalk-api-secrets
|
|
||||||
key: YOUTUBE_API_KEY
|
|
||||||
- name: PORT
|
|
||||||
value: "3001"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 3001
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 3001
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "32Mi"
|
|
||||||
cpu: "10m"
|
|
||||||
limits:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "200m"
|
|
||||||
|
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: gitea-registry
|
- name: gitea-registry
|
||||||
|
|||||||
@ -3,14 +3,6 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://localhost:3001;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { useRef, useState, useCallback } from 'react'
|
import { useRef, useState, useCallback } from 'react'
|
||||||
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2 } from 'lucide-react'
|
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
import { formatDuration } from '@/lib/utils'
|
import { formatDuration } from '@/lib/utils'
|
||||||
import { publicUrl } from '@/lib/storage'
|
import { publicUrl } from '@/lib/storage'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
|
||||||
|
|
||||||
export function PlayerBar() {
|
export function PlayerBar() {
|
||||||
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
||||||
const seekRef = useRef<HTMLDivElement>(null)
|
const seekRef = useRef<HTMLDivElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [dragPct, setDragPct] = useState(0)
|
const [dragPct, setDragPct] = useState(0)
|
||||||
const [ytExpanded, setYtExpanded] = useState(false)
|
|
||||||
|
|
||||||
const calcPct = useCallback((clientX: number) => {
|
const calcPct = useCallback((clientX: number) => {
|
||||||
if (!seekRef.current) return 0
|
if (!seekRef.current) return 0
|
||||||
@ -21,9 +19,6 @@ export function PlayerBar() {
|
|||||||
|
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
|
|
||||||
const embedInfo = isExternal && current ? getEmbedInfo(current.audio_url) : null
|
|
||||||
const isYouTube = embedInfo?.platform === 'youtube'
|
|
||||||
const isSpotify = embedInfo?.platform === 'spotify'
|
|
||||||
const pct = duration > 0 ? (progress / duration) * 100 : 0
|
const pct = duration > 0 ? (progress / duration) * 100 : 0
|
||||||
const displayPct = isDragging ? dragPct : pct
|
const displayPct = isDragging ? dragPct : pct
|
||||||
// Allow seeking for native audio and YouTube (via IFrame API)
|
// Allow seeking for native audio and YouTube (via IFrame API)
|
||||||
@ -77,41 +72,6 @@ export function PlayerBar() {
|
|||||||
const currentTime = isDragging ? (dragPct / 100) * duration : progress
|
const currentTime = isDragging ? (dragPct / 100) * duration : progress
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{/* YouTube player - mini or expanded */}
|
|
||||||
{isYouTube && (
|
|
||||||
<div
|
|
||||||
className={`fixed z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ${
|
|
||||||
ytExpanded
|
|
||||||
? 'inset-4 bottom-[106px]'
|
|
||||||
: 'bottom-[106px] right-4'
|
|
||||||
}`}
|
|
||||||
style={ytExpanded ? {} : { width: 200, height: 150 }}
|
|
||||||
>
|
|
||||||
<div id="yt-player-portal" className="w-full h-full [&_iframe]:!w-full [&_iframe]:!h-full [&_iframe]:!border-0" />
|
|
||||||
{/* Gradient overlay to hide YouTube title bar */}
|
|
||||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/80 to-transparent pointer-events-none" />
|
|
||||||
<button
|
|
||||||
onClick={() => setYtExpanded(!ytExpanded)}
|
|
||||||
className="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-black/60 hover:bg-black/80 flex items-center justify-center text-white transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{ytExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Spotify player - mini floating */}
|
|
||||||
{isSpotify && embedInfo && (
|
|
||||||
<div className="fixed bottom-[106px] right-4 z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
|
||||||
<iframe
|
|
||||||
src={embedInfo.embedUrl}
|
|
||||||
width="300"
|
|
||||||
height="152"
|
|
||||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
||||||
loading="lazy"
|
|
||||||
style={{ border: 0, borderRadius: 12 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#1E1B33] text-white shadow-[0_-2px_20px_rgba(0,0,0,0.3)]">
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#1E1B33] text-white shadow-[0_-2px_20px_rgba(0,0,0,0.3)]">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
@ -232,6 +192,5 @@ export function PlayerBar() {
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type EmbedInfo = {
|
export type EmbedInfo = {
|
||||||
platform: 'youtube' | 'dailymotion' | 'soundcloud' | 'spotify'
|
platform: 'youtube' | 'dailymotion' | 'soundcloud'
|
||||||
id: string
|
id: string
|
||||||
embedUrl: string
|
embedUrl: string
|
||||||
}
|
}
|
||||||
@ -38,20 +38,6 @@ export function getEmbedInfo(url: string): EmbedInfo | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotify (episodes, shows, tracks)
|
|
||||||
const spotifyMatch = url.match(
|
|
||||||
/open\.spotify\.com\/(episode|show|track)\/([a-zA-Z0-9]+)/,
|
|
||||||
)
|
|
||||||
if (spotifyMatch) {
|
|
||||||
const type = spotifyMatch[1]
|
|
||||||
const id = spotifyMatch[2]
|
|
||||||
return {
|
|
||||||
platform: 'spotify',
|
|
||||||
id,
|
|
||||||
embedUrl: `https://open.spotify.com/embed/${type}/${id}?utm_source=generator&theme=0`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,16 +18,148 @@ interface OEmbedData {
|
|||||||
author: string
|
author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
function extractVideoId(url: string): { platform: string; id: string } | null {
|
||||||
|
// YouTube
|
||||||
|
const ytMatch = url.match(
|
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
)
|
||||||
|
if (ytMatch) return { platform: 'youtube', id: ytMatch[1] }
|
||||||
|
|
||||||
|
// Dailymotion
|
||||||
|
const dmMatch = url.match(
|
||||||
|
/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/,
|
||||||
|
)
|
||||||
|
if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] }
|
||||||
|
|
||||||
|
// SoundCloud (handled via oEmbed, no ID extraction needed)
|
||||||
|
if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ISO 8601 duration (PT1H2M30S) to seconds
|
||||||
|
function parseDuration(iso: string): number {
|
||||||
|
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
|
||||||
|
if (!match) return 0
|
||||||
|
const h = parseInt(match[1] || '0')
|
||||||
|
const m = parseInt(match[2] || '0')
|
||||||
|
const s = parseInt(match[3] || '0')
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
const YT_API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY || ''
|
||||||
|
|
||||||
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
||||||
|
const info = extractVideoId(url)
|
||||||
|
if (!info) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
|
if (info.platform === 'youtube') {
|
||||||
if (!res.ok) return null
|
// Use YouTube Data API v3 if key is available
|
||||||
return await res.json()
|
if (YT_API_KEY) {
|
||||||
|
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails,statistics&id=${info.id}&key=${YT_API_KEY}`
|
||||||
|
const res = await fetch(apiUrl)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const video = data.items?.[0]
|
||||||
|
if (video) {
|
||||||
|
const snippet = video.snippet || {}
|
||||||
|
const contentDetails = video.contentDetails || {}
|
||||||
|
const stats = video.statistics || {}
|
||||||
|
// Best thumbnail: maxres > standard > high > medium > default
|
||||||
|
const thumbs = snippet.thumbnails || {}
|
||||||
|
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
|
||||||
|
const duration = parseDuration(contentDetails.duration || '')
|
||||||
|
const viewCount = parseInt(stats.viewCount || '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: snippet.title || '',
|
||||||
|
description: snippet.description?.slice(0, 500) || '',
|
||||||
|
thumbnail: thumb,
|
||||||
|
duration,
|
||||||
|
audioUrl: url,
|
||||||
|
platform: 'YouTube',
|
||||||
|
author: snippet.channelTitle || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to oEmbed (no duration but basic metadata)
|
||||||
|
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`
|
||||||
|
const res = await fetch(oembedUrl)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.author_name ? `Par ${data.author_name}` : '',
|
||||||
|
thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`,
|
||||||
|
duration: 0,
|
||||||
|
audioUrl: url,
|
||||||
|
platform: 'YouTube',
|
||||||
|
author: data.author_name || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.platform === 'dailymotion') {
|
||||||
|
// Dailymotion public Data API — returns duration, description, tags, author
|
||||||
|
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname,tags,created_time`
|
||||||
|
const res = await fetch(apiUrl)
|
||||||
|
if (!res.ok) {
|
||||||
|
// Fallback to oEmbed
|
||||||
|
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json`
|
||||||
|
const oeRes = await fetch(oembedUrl)
|
||||||
|
if (!oeRes.ok) return null
|
||||||
|
const oeData = await oeRes.json()
|
||||||
|
return {
|
||||||
|
title: oeData.title || '',
|
||||||
|
description: '',
|
||||||
|
thumbnail: oeData.thumbnail_url || '',
|
||||||
|
duration: 0,
|
||||||
|
audioUrl: url,
|
||||||
|
platform: 'Dailymotion',
|
||||||
|
author: oeData.author_name || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const owner = data.owner?.screenname || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || (owner ? `Par ${owner}` : ''),
|
||||||
|
thumbnail: data.thumbnail_720_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
|
||||||
|
duration: data.duration || 0,
|
||||||
|
audioUrl: url,
|
||||||
|
platform: 'Dailymotion',
|
||||||
|
author: owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.platform === 'soundcloud') {
|
||||||
|
const oembedUrl = `https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`
|
||||||
|
const res = await fetch(oembedUrl)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
// Extract duration from the iframe embed (SoundCloud embeds sometimes include it)
|
||||||
|
// Parse the HTML to get the widget URL for potential API calls
|
||||||
|
const author = data.author_name || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || (author ? `Par ${author}` : ''),
|
||||||
|
thumbnail: data.thumbnail_url || '',
|
||||||
|
duration: 0,
|
||||||
|
audioUrl: url,
|
||||||
|
platform: 'SoundCloud',
|
||||||
|
author,
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Upload() {
|
export function Upload() {
|
||||||
@ -314,7 +446,6 @@ export function Upload() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1.5 mt-4">
|
<div className="flex gap-1.5 mt-4">
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span>
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span>
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Spotify</span>
|
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Dailymotion</span>
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Dailymotion</span>
|
||||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">SoundCloud</span>
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">SoundCloud</span>
|
||||||
</div>
|
</div>
|
||||||
@ -471,7 +602,7 @@ export function Upload() {
|
|||||||
label="URL de la video ou du podcast"
|
label="URL de la video ou du podcast"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
|
placeholder="https://youtube.com/watch?v=... ou https://dai.ly/..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
|
|||||||
@ -50,15 +50,7 @@ function destroyYtPlayer() {
|
|||||||
clearProgressInterval()
|
clearProgressInterval()
|
||||||
try { ytPlayer?.destroy() } catch { /* ignore */ }
|
try { ytPlayer?.destroy() } catch { /* ignore */ }
|
||||||
ytPlayer = null
|
ytPlayer = null
|
||||||
if (ytContainer) {
|
if (ytContainer) { ytContainer.remove(); ytContainer = null }
|
||||||
// If it's the portal, just clear contents; otherwise remove from DOM
|
|
||||||
if (ytContainer.id === 'yt-player-portal') {
|
|
||||||
ytContainer.innerHTML = ''
|
|
||||||
} else {
|
|
||||||
ytContainer.remove()
|
|
||||||
}
|
|
||||||
ytContainer = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyIframe() {
|
function destroyIframe() {
|
||||||
@ -186,26 +178,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
// Wait for previous save to complete, then fetch saved progress
|
// Wait for previous save to complete, then fetch saved progress
|
||||||
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
||||||
loadYouTubeAPI().then(() => {
|
loadYouTubeAPI().then(() => {
|
||||||
// Use portal target if available, otherwise create visible container
|
ytContainer = document.createElement('div')
|
||||||
const portalTarget = document.getElementById('yt-player-portal')
|
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
||||||
if (portalTarget) {
|
|
||||||
portalTarget.innerHTML = ''
|
|
||||||
ytContainer = portalTarget as HTMLDivElement
|
|
||||||
} else {
|
|
||||||
ytContainer = document.createElement('div')
|
|
||||||
ytContainer.style.cssText = 'position:fixed;bottom:90px;right:16px;width:200px;height:200px;z-index:60;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.3);'
|
|
||||||
document.body.appendChild(ytContainer)
|
|
||||||
}
|
|
||||||
const playerDiv = document.createElement('div')
|
const playerDiv = document.createElement('div')
|
||||||
playerDiv.id = 'yt-player-' + Date.now()
|
playerDiv.id = 'yt-player-' + Date.now()
|
||||||
ytContainer.appendChild(playerDiv)
|
ytContainer.appendChild(playerDiv)
|
||||||
|
document.body.appendChild(ytContainer)
|
||||||
|
|
||||||
ytPlayer = new window.YT.Player(playerDiv.id, {
|
ytPlayer = new window.YT.Player(playerDiv.id, {
|
||||||
videoId: embed.id,
|
videoId: embed.id,
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 1,
|
autoplay: 1,
|
||||||
controls: 1,
|
controls: 0,
|
||||||
fs: 1,
|
disablekb: 1,
|
||||||
|
fs: 0,
|
||||||
modestbranding: 1,
|
modestbranding: 1,
|
||||||
rel: 0,
|
rel: 0,
|
||||||
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
|
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
|
||||||
@ -250,12 +236,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Spotify: embed is rendered in PlayerBar, no hidden iframe needed ──
|
|
||||||
if (external && embed?.platform === 'spotify') {
|
|
||||||
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Other external (Dailymotion, SoundCloud): hidden iframe ──
|
// ── Other external (Dailymotion, SoundCloud): hidden iframe ──
|
||||||
if (external && embed) {
|
if (external && embed) {
|
||||||
createHiddenIframe(embed.embedUrl)
|
createHiddenIframe(embed.embedUrl)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user