Compare commits
3 Commits
a7c4aa5608
...
1ae040cdd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae040cdd3 | ||
|
|
569b5a5846 | ||
|
|
49dcfb28de |
@ -1,2 +1,3 @@
|
|||||||
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,7 +6,8 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.arthurbarre.fr
|
REGISTRY: git.arthurbarre.fr
|
||||||
IMAGE: ordinarthur/wetalk
|
IMAGE_FRONT: ordinarthur/wetalk
|
||||||
|
IMAGE_API: ordinarthur/wetalk-api
|
||||||
NAMESPACE: wetalk
|
NAMESPACE: wetalk
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@ -23,17 +24,27 @@ jobs:
|
|||||||
username: ordinarthur
|
username: ordinarthur
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push frontend
|
||||||
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 }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:${{ 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: |
|
||||||
@ -57,13 +68,21 @@ 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 image
|
# Force rollout with new images
|
||||||
kubectl -n $NAMESPACE set image deployment/wetalk \
|
kubectl -n $NAMESPACE set image deployment/wetalk \
|
||||||
wetalk=$REGISTRY/$IMAGE:${{ github.sha }}
|
wetalk=$REGISTRY/$IMAGE_FRONT:${{ 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,6 +5,7 @@ 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
|
||||||
|
|||||||
2
api/.dockerignore
Normal file
2
api/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
4
api/.env.example
Normal file
4
api/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
YOUTUBE_API_KEY=
|
||||||
|
PORT=3001
|
||||||
8
api/Dockerfile
Normal file
8
api/Dockerfile
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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
Normal file
15
api/bun.lock
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "wetalk-api",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
12
api/package.json
Normal file
12
api/package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
234
api/src/index.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
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,3 +12,15 @@ 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,5 +37,48 @@ 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,6 +3,14 @@ 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,15 +1,17 @@
|
|||||||
import { useRef, useState, useCallback } from 'react'
|
import { useRef, useState, useCallback } from 'react'
|
||||||
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2 } 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
|
||||||
@ -19,6 +21,9 @@ 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)
|
||||||
@ -72,6 +77,41 @@ 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 */}
|
||||||
@ -192,5 +232,6 @@ export function PlayerBar() {
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type EmbedInfo = {
|
export type EmbedInfo = {
|
||||||
platform: 'youtube' | 'dailymotion' | 'soundcloud'
|
platform: 'youtube' | 'dailymotion' | 'soundcloud' | 'spotify'
|
||||||
id: string
|
id: string
|
||||||
embedUrl: string
|
embedUrl: string
|
||||||
}
|
}
|
||||||
@ -38,6 +38,20 @@ 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,148 +18,16 @@ interface OEmbedData {
|
|||||||
author: string
|
author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractVideoId(url: string): { platform: string; id: string } | null {
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
// 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 {
|
||||||
if (info.platform === 'youtube') {
|
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
|
||||||
// Use YouTube Data API v3 if key is available
|
if (!res.ok) return null
|
||||||
if (YT_API_KEY) {
|
return await res.json()
|
||||||
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() {
|
||||||
@ -446,6 +314,7 @@ 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>
|
||||||
@ -602,7 +471,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://dai.ly/..."
|
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
|
|||||||
@ -50,7 +50,15 @@ function destroyYtPlayer() {
|
|||||||
clearProgressInterval()
|
clearProgressInterval()
|
||||||
try { ytPlayer?.destroy() } catch { /* ignore */ }
|
try { ytPlayer?.destroy() } catch { /* ignore */ }
|
||||||
ytPlayer = null
|
ytPlayer = null
|
||||||
if (ytContainer) { ytContainer.remove(); ytContainer = null }
|
if (ytContainer) {
|
||||||
|
// 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() {
|
||||||
@ -178,20 +186,26 @@ 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(() => {
|
||||||
ytContainer = document.createElement('div')
|
// Use portal target if available, otherwise create visible container
|
||||||
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
const portalTarget = document.getElementById('yt-player-portal')
|
||||||
|
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: 0,
|
controls: 1,
|
||||||
disablekb: 1,
|
fs: 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,
|
||||||
@ -236,6 +250,12 @@ 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