Compare commits
52 Commits
vercel/rea
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
16a8971c57 | |
|
|
a192dcaa7f | |
|
|
62f33a2565 | |
|
|
c5aa2f0e40 | |
|
|
b9351cf5d6 | |
|
|
fdb1efcb1e | |
|
|
8e62906661 | |
|
|
c9cd56b452 | |
|
|
770faea730 | |
|
|
04e6945a51 | |
|
|
252c69d648 | |
|
|
b56f8c4030 | |
|
|
d6d47410c7 | |
|
|
9335c0a4ce | |
|
|
7be3c75d79 | |
|
|
524eb4bbbb | |
|
|
c2af4327aa | |
|
|
02278f4cf8 | |
|
|
4694db4f95 | |
|
|
345fbca1a9 | |
|
|
f5dcc3c804 | |
|
|
47d7565d04 | |
|
|
c24962f829 | |
|
|
0f76ce3cd7 | |
|
|
b86e7e7a1a | |
|
|
b122d00be3 | |
|
|
1bdb5e50ef | |
|
|
a54e003196 | |
|
|
b9551d7597 | |
|
|
07e64e0627 | |
|
|
8c79b97ef7 | |
|
|
cee87aef10 | |
|
|
709a87731c | |
|
|
717eed7100 | |
|
|
2673a247bc | |
|
|
228f3c6658 | |
|
|
9ea2a0ff9f | |
|
|
2c8614e01e | |
|
|
b43a72a080 | |
|
|
547431e9db | |
|
|
99f9f0d681 | |
|
|
f550fe0423 | |
|
|
3a2604ea2e | |
|
|
a99d51c831 | |
|
|
95ac4129b3 | |
|
|
38dc171af7 | |
|
|
279bd30c72 | |
|
|
1821194648 | |
|
|
f66e6d7434 | |
|
|
22f0ab2fe6 | |
|
|
7a01ea37c2 | |
|
|
73988e6378 |
|
|
@ -0,0 +1,27 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Development
|
||||
node_modules
|
||||
.next
|
||||
.cache
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
CLAUDE.md
|
||||
*.md
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment (keep .env.example)
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# SMTP settings (Mailcow)
|
||||
SMTP_HOST=mail.rmail.online
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@jefflix.lol
|
||||
SMTP_PASS=your-mailbox-password
|
||||
|
||||
# Admin email to receive access request notifications
|
||||
ADMIN_EMAIL=jeff@jeffemmett.com
|
||||
|
||||
# Channel approval token signing secret (generate with: openssl rand -hex 32)
|
||||
TOKEN_SECRET=your-random-secret-here
|
||||
|
||||
# Threadfin credentials for one-click channel activation
|
||||
THREADFIN_USER=your-threadfin-username
|
||||
THREADFIN_PASS=your-threadfin-password
|
||||
|
||||
# Navidrome (Music / Subsonic API)
|
||||
NAVIDROME_URL=https://music.jefflix.lol
|
||||
NAVIDROME_USER=your-navidrome-username
|
||||
NAVIDROME_PASS=your-navidrome-password
|
||||
|
||||
# slskd (Soulseek P2P downloads)
|
||||
SLSKD_URL=https://slskd.jefflix.lol
|
||||
SLSKD_API_KEY=your-slskd-api-key
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Gitea Actions CI/CD — Static Site (no tests, build + deploy only)
|
||||
# Copy to: <repo>/.gitea/workflows/ci.yml
|
||||
# Replace: jefflix-website, /opt/websites/jefflix-website, https://jefflix.lol/
|
||||
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: localhost:3000
|
||||
IMAGE: localhost:3000/jeffemmett/jefflix-website
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:cli
|
||||
steps:
|
||||
- name: Setup tools
|
||||
run: apk add --no-cache git openssh-client curl
|
||||
|
||||
- name: Checkout
|
||||
run: git clone --depth 1 --branch ${{ github.ref_name }} http://token:${{ github.token }}@server:3000/${{ github.repository }}.git .
|
||||
|
||||
- name: Set image tag
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||
echo "IMAGE_TAG=${SHORT_SHA}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
docker build -t ${{ env.IMAGE }}:${{ env.IMAGE_TAG }} -t ${{ env.IMAGE }}:latest .
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
docker push ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
|
||||
docker push ${{ env.IMAGE }}:latest
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "
|
||||
cd /opt/websites/jefflix-website
|
||||
cat .last-deployed-tag 2>/dev/null > .rollback-tag || true
|
||||
echo '${{ env.IMAGE_TAG }}' > .last-deployed-tag
|
||||
docker pull ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
|
||||
IMAGE_TAG=${{ env.IMAGE_TAG }} docker compose up -d --no-build
|
||||
"
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
sleep 15
|
||||
STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
"cd /opt/websites/jefflix-website && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'")
|
||||
if echo "$STATUS" | grep -qi "up"; then
|
||||
echo "Smoke test passed (container status: $STATUS)"
|
||||
else
|
||||
echo "Smoke test failed (container status: $STATUS) — rolling back"
|
||||
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/jefflix-website/.rollback-tag 2>/dev/null")
|
||||
if [ -n "$ROLLBACK_TAG" ]; then
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
"cd /opt/websites/jefflix-website && IMAGE_TAG=$ROLLBACK_TAG docker compose up -d --no-build"
|
||||
echo "Rolled back to $ROLLBACK_TAG"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environment files (keep .env.example)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
29
README.md
29
README.md
|
|
@ -1,30 +1 @@
|
|||
# Jefflix website
|
||||
|
||||
*Automatically synced with your [v0.app](https://v0.app) deployments*
|
||||
|
||||
[](https://vercel.com/jeff-emmetts-projects/v0-jefflix-website)
|
||||
[](https://v0.app/chat/rSGm1BAgi15)
|
||||
|
||||
## Overview
|
||||
|
||||
This repository will stay in sync with your deployed chats on [v0.app](https://v0.app).
|
||||
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
|
||||
|
||||
## Deployment
|
||||
|
||||
Your project is live at:
|
||||
|
||||
**[https://vercel.com/jeff-emmetts-projects/v0-jefflix-website](https://vercel.com/jeff-emmetts-projects/v0-jefflix-website)**
|
||||
|
||||
## Build your app
|
||||
|
||||
Continue building your app on:
|
||||
|
||||
**[https://v0.app/chat/rSGm1BAgi15](https://v0.app/chat/rSGm1BAgi15)**
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Create and modify your project using [v0.app](https://v0.app)
|
||||
2. Deploy your chats from the v0 interface
|
||||
3. Changes are automatically pushed to this repository
|
||||
4. Vercel deploys the latest version from this repository
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { NextRequest } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { verifyApproveToken } from '@/lib/token'
|
||||
import { activateChannels } from '@/lib/threadfin'
|
||||
|
||||
function htmlPage(title: string, body: string): Response {
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title} — Jefflix</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; background: #0a0a0a; color: #e5e5e5; }
|
||||
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 32px; }
|
||||
.icon { font-size: 48px; margin-bottom: 16px; }
|
||||
h1 { margin: 0 0 16px; font-size: 24px; }
|
||||
ul { padding-left: 20px; line-height: 1.8; }
|
||||
code { background: #2a2a2a; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
.success { color: #4ade80; }
|
||||
.warning { color: #facc15; }
|
||||
.error { color: #f87171; }
|
||||
.muted { color: #888; font-size: 13px; margin-top: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
${body}
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return htmlPage('Invalid Link', `
|
||||
<div class="icon">❌</div>
|
||||
<h1 class="error">Invalid Link</h1>
|
||||
<p>No approval token provided.</p>
|
||||
`)
|
||||
}
|
||||
|
||||
const payload = verifyApproveToken(token)
|
||||
if (!payload) {
|
||||
return htmlPage('Expired or Invalid', `
|
||||
<div class="icon">⏳</div>
|
||||
<h1 class="error">Link Expired or Invalid</h1>
|
||||
<p>This approval link has expired or is invalid. Channel requests expire after 7 days.</p>
|
||||
<p>Ask the user to submit a new request.</p>
|
||||
`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await activateChannels(
|
||||
payload.channels.map((ch) => ch.id),
|
||||
)
|
||||
|
||||
// Send confirmation email to the requester
|
||||
await sendConfirmationEmail(payload.email, result.activated, result.notFound, payload.channels)
|
||||
|
||||
const activatedHtml = result.activated.length > 0
|
||||
? `<h2 class="success">Activated (${result.activated.length})</h2>
|
||||
<ul>${result.activated.map((id) => {
|
||||
const ch = payload.channels.find((c) => c.id === id)
|
||||
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
|
||||
}).join('')}</ul>`
|
||||
: ''
|
||||
|
||||
const notFoundHtml = result.notFound.length > 0
|
||||
? `<h2 class="warning">Not Found in Playlists (${result.notFound.length})</h2>
|
||||
<ul>${result.notFound.map((id) => {
|
||||
const ch = payload.channels.find((c) => c.id === id)
|
||||
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
|
||||
}).join('')}</ul>
|
||||
<p class="muted">These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.</p>`
|
||||
: ''
|
||||
|
||||
return htmlPage('Channels Approved', `
|
||||
<div class="icon">✅</div>
|
||||
<h1 class="success">Channels Approved</h1>
|
||||
<p>Request from <strong>${escapeHtml(payload.email)}</strong> has been processed.</p>
|
||||
${activatedHtml}
|
||||
${notFoundHtml}
|
||||
<p class="muted">A confirmation email has been sent to the requester.</p>
|
||||
`)
|
||||
} catch (err) {
|
||||
console.error('Channel activation error:', err)
|
||||
return htmlPage('Activation Failed', `
|
||||
<div class="icon">❌</div>
|
||||
<h1 class="error">Activation Failed</h1>
|
||||
<p>Could not connect to Threadfin or activate channels.</p>
|
||||
<p><code>${escapeHtml(err instanceof Error ? err.message : String(err))}</code></p>
|
||||
<p>Check Threadfin is running and credentials are correct.</p>
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendConfirmationEmail(
|
||||
to: string,
|
||||
activated: string[],
|
||||
notFound: string[],
|
||||
channels: { id: string; name: string }[],
|
||||
) {
|
||||
const smtpHost = process.env.SMTP_HOST
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
if (!smtpHost || !smtpUser || !smtpPass) return
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: false,
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
const activatedList = activated.map((id) => {
|
||||
const ch = channels.find((c) => c.id === id)
|
||||
return `<li>✅ <strong>${escapeHtml(ch?.name ?? id)}</strong></li>`
|
||||
}).join('')
|
||||
|
||||
const notFoundList = notFound.map((id) => {
|
||||
const ch = channels.find((c) => c.id === id)
|
||||
return `<li>❌ <strong>${escapeHtml(ch?.name ?? id)}</strong> — not available in current playlists</li>`
|
||||
}).join('')
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Jefflix <${smtpUser}>`,
|
||||
to,
|
||||
subject: `[Jefflix] Your channel request has been processed`,
|
||||
html: `
|
||||
<h2>Your Channel Request Update</h2>
|
||||
${activated.length > 0 ? `
|
||||
<p>The following channels have been activated and should appear in your guide shortly:</p>
|
||||
<ul style="line-height: 1.8;">${activatedList}</ul>
|
||||
` : ''}
|
||||
${notFound.length > 0 ? `
|
||||
<p>The following channels were not found in the current playlists:</p>
|
||||
<ul style="line-height: 1.8;">${notFoundList}</ul>
|
||||
<p style="color: #666; font-size: 13px;">These may become available when new playlist sources are added.</p>
|
||||
` : ''}
|
||||
${activated.length > 0 ? '<p>It may take a few minutes for the channels to appear in your TV guide. If you don\'t see them after 15 minutes, try refreshing your IPTV app.</p>' : ''}
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||
<p style="color: #666; font-size: 12px;">Jefflix · ${new Date().toLocaleString()}</p>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
const IPTV_ORG_URL = 'https://iptv-org.github.io/api/channels.json'
|
||||
|
||||
interface IptvChannel {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
categories: string[]
|
||||
is_nsfw: boolean
|
||||
is_closed: boolean
|
||||
}
|
||||
|
||||
interface SlimChannel {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
let cache: { data: SlimChannel[]; fetchedAt: number } | null = null
|
||||
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
|
||||
return NextResponse.json(cache.data)
|
||||
}
|
||||
|
||||
const res = await fetch(IPTV_ORG_URL, { next: { revalidate: 3600 } })
|
||||
if (!res.ok) {
|
||||
throw new Error(`iptv-org API returned ${res.status}`)
|
||||
}
|
||||
|
||||
const channels: IptvChannel[] = await res.json()
|
||||
|
||||
const filtered: SlimChannel[] = channels
|
||||
.filter((ch) => !ch.is_nsfw && !ch.is_closed)
|
||||
.map(({ id, name, country, categories }) => ({ id, name, country, categories }))
|
||||
|
||||
cache = { data: filtered, fetchedAt: Date.now() }
|
||||
|
||||
return NextResponse.json(filtered)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channels:', error)
|
||||
// Return cached data even if stale on error
|
||||
if (cache) {
|
||||
return NextResponse.json(cache.data)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load channel list' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { navidromeFetch } from '@/lib/navidrome'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const size = searchParams.get('size') || '300'
|
||||
|
||||
try {
|
||||
const res = await navidromeFetch('getCoverArt.view', { id, size })
|
||||
const contentType = res.headers.get('content-type') || 'image/jpeg'
|
||||
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cover art error:', error)
|
||||
return new Response('Cover art failed', { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
interface LyricsResult {
|
||||
lyrics?: {
|
||||
artist?: string
|
||||
title?: string
|
||||
value?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LrcLibResult {
|
||||
syncedLyrics?: string | null
|
||||
plainLyrics?: string | null
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const artist = searchParams.get('artist') || ''
|
||||
const title = searchParams.get('title') || ''
|
||||
|
||||
if (!artist || !title) {
|
||||
return NextResponse.json({ lyrics: null, synced: null })
|
||||
}
|
||||
|
||||
// Try LRCLIB first for synced (timed) lyrics
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 4000)
|
||||
|
||||
const lrcRes = await fetch(
|
||||
`https://lrclib.net/api/get?artist_name=${encodeURIComponent(artist)}&track_name=${encodeURIComponent(title)}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
|
||||
},
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (lrcRes.ok) {
|
||||
const lrc: LrcLibResult = await lrcRes.json()
|
||||
if (lrc.syncedLyrics) {
|
||||
return NextResponse.json({
|
||||
lyrics: lrc.plainLyrics || lrc.syncedLyrics.replace(/\[\d{2}:\d{2}\.\d{2,3}\]\s?/g, ''),
|
||||
synced: lrc.syncedLyrics,
|
||||
})
|
||||
}
|
||||
if (lrc.plainLyrics) {
|
||||
return NextResponse.json({ lyrics: lrc.plainLyrics, synced: null })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// LRCLIB unavailable, fall through to Navidrome
|
||||
}
|
||||
|
||||
// Fallback to Navidrome plain lyrics
|
||||
try {
|
||||
const data = await navidromeGet<LyricsResult>('getLyrics.view', { artist, title })
|
||||
return NextResponse.json({
|
||||
lyrics: data.lyrics?.value || null,
|
||||
synced: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Lyrics error:', error)
|
||||
return NextResponse.json({ lyrics: null, synced: null })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
interface MBRecording {
|
||||
id: string
|
||||
title: string
|
||||
score: number
|
||||
length?: number
|
||||
'artist-credit'?: { name: string }[]
|
||||
releases?: { title: string; date?: string }[]
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const q = searchParams.get('q')
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
return NextResponse.json({ results: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const res = await fetch(
|
||||
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=20`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`MusicBrainz returned ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const results = (data.recordings || []).map((r: MBRecording) => ({
|
||||
mbid: r.id,
|
||||
title: r.title,
|
||||
artist: r['artist-credit']?.[0]?.name || 'Unknown',
|
||||
album: r.releases?.[0]?.title || '',
|
||||
year: r.releases?.[0]?.date?.slice(0, 4) || '',
|
||||
duration: r.length ? Math.round(r.length / 1000) : 0,
|
||||
score: r.score,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ results })
|
||||
} catch (error) {
|
||||
console.error('MusicBrainz search error:', error)
|
||||
return NextResponse.json({ error: 'MusicBrainz search failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
const OFFLINE_PLAYLIST_NAME = '__soulsync_offline__'
|
||||
|
||||
interface SubsonicPlaylist {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
interface SubsonicSong {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumId: string
|
||||
duration: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
interface PlaylistsResult {
|
||||
playlists?: { playlist?: SubsonicPlaylist[] }
|
||||
}
|
||||
|
||||
interface PlaylistResult {
|
||||
playlist?: {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
entry?: SubsonicSong[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Find or create the offline sync playlist, returning its id + songs */
|
||||
async function getOrCreateOfflinePlaylist() {
|
||||
// Find existing
|
||||
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
||||
const existing = (data.playlists?.playlist || []).find(
|
||||
(p) => p.name === OFFLINE_PLAYLIST_NAME
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
// Fetch full playlist with entries
|
||||
const full = await navidromeGet<PlaylistResult>('getPlaylist.view', { id: existing.id })
|
||||
const songs = (full.playlist?.entry || []).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
artist: s.artist,
|
||||
album: s.album,
|
||||
albumId: s.albumId,
|
||||
duration: s.duration,
|
||||
coverArt: s.coverArt,
|
||||
}))
|
||||
return { id: existing.id, songs }
|
||||
}
|
||||
|
||||
// Create it
|
||||
await navidromeGet('createPlaylist.view', { name: OFFLINE_PLAYLIST_NAME })
|
||||
// Re-fetch to get its id
|
||||
const data2 = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
||||
const created = (data2.playlists?.playlist || []).find(
|
||||
(p) => p.name === OFFLINE_PLAYLIST_NAME
|
||||
)
|
||||
return { id: created?.id || '', songs: [] }
|
||||
}
|
||||
|
||||
/** GET: return offline playlist id + songs */
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await getOrCreateOfflinePlaylist()
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Offline playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get offline playlist' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST: add a song to the offline playlist */
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { songId } = await request.json()
|
||||
if (!songId) {
|
||||
return NextResponse.json({ error: 'songId required' }, { status: 400 })
|
||||
}
|
||||
const { id: playlistId } = await getOrCreateOfflinePlaylist()
|
||||
await navidromeGet('updatePlaylist.view', {
|
||||
playlistId,
|
||||
songIdToAdd: songId,
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Add to offline playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to add song' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE: remove a song from the offline playlist by songId */
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { songId } = await request.json()
|
||||
if (!songId) {
|
||||
return NextResponse.json({ error: 'songId required' }, { status: 400 })
|
||||
}
|
||||
const { id: playlistId, songs } = await getOrCreateOfflinePlaylist()
|
||||
// Subsonic removeFromPlaylist uses songIndexToRemove (0-based index)
|
||||
const index = songs.findIndex((s) => s.id === songId)
|
||||
if (index === -1) {
|
||||
return NextResponse.json({ error: 'Song not in offline playlist' }, { status: 404 })
|
||||
}
|
||||
await navidromeGet('updatePlaylist.view', {
|
||||
playlistId,
|
||||
songIndexToRemove: String(index),
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Remove from offline playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to remove song' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
interface SubsonicSong {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumId: string
|
||||
duration: number
|
||||
track: number
|
||||
year: number
|
||||
coverArt: string
|
||||
suffix: string
|
||||
}
|
||||
|
||||
interface PlaylistResult {
|
||||
playlist?: {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
entry?: SubsonicSong[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const data = await navidromeGet<PlaylistResult>('getPlaylist.view', { id })
|
||||
const pl = data.playlist
|
||||
if (!pl) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 })
|
||||
|
||||
// Dedup by title+artist, keeping first occurrence
|
||||
const seen = new Set<string>()
|
||||
const songs = (pl.entry || []).reduce<Array<{
|
||||
id: string; title: string; artist: string; album: string; albumId: string;
|
||||
duration: number; track: number; year: number; coverArt: string; suffix: string;
|
||||
}>>((acc, s) => {
|
||||
const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
acc.push({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, track: s.track,
|
||||
year: s.year, coverArt: s.coverArt, suffix: s.suffix,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return NextResponse.json({
|
||||
id: pl.id,
|
||||
name: pl.name,
|
||||
songCount: pl.songCount,
|
||||
coverArt: pl.coverArt,
|
||||
songs,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to load playlist' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const { songId } = await request.json()
|
||||
if (!songId) {
|
||||
return NextResponse.json({ error: 'songId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await navidromeGet('updatePlaylist.view', {
|
||||
playlistId: id,
|
||||
songIdToAdd: songId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Add to playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to add to playlist' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, songId } = await request.json()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const params: Record<string, string> = { name }
|
||||
if (songId) params.songId = songId
|
||||
|
||||
await navidromeGet('createPlaylist.view', params)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Create playlist error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create playlist' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
interface SubsonicPlaylist {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
duration: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
interface PlaylistsResult {
|
||||
playlists?: {
|
||||
playlist?: SubsonicPlaylist[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
|
||||
const playlists = (data.playlists?.playlist || [])
|
||||
.filter((p) => p.name !== '__soulsync_offline__')
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
songCount: p.songCount,
|
||||
duration: p.duration,
|
||||
coverArt: p.coverArt,
|
||||
}))
|
||||
return NextResponse.json({ playlists })
|
||||
} catch (error) {
|
||||
console.error('Playlists error:', error)
|
||||
return NextResponse.json({ error: 'Failed to load playlists' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { navidromeGet } from '@/lib/navidrome'
|
||||
|
||||
interface SubsonicSong {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumId: string
|
||||
duration: number
|
||||
track: number
|
||||
year: number
|
||||
coverArt: string
|
||||
suffix: string
|
||||
bitRate: number
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
searchResult3?: {
|
||||
song?: SubsonicSong[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const q = searchParams.get('q')
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
return NextResponse.json({ songs: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await navidromeGet<SearchResult>('search3.view', {
|
||||
query: q,
|
||||
songCount: '50',
|
||||
albumCount: '0',
|
||||
artistCount: '0',
|
||||
})
|
||||
|
||||
const rawSongs = data.searchResult3?.song || []
|
||||
|
||||
// Dedup by title+artist, keeping highest bitRate (then most recent year)
|
||||
const seen = new Map<string, SubsonicSong>()
|
||||
for (const s of rawSongs) {
|
||||
const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
|
||||
const existing = seen.get(key)
|
||||
if (!existing ||
|
||||
(s.bitRate || 0) > (existing.bitRate || 0) ||
|
||||
((s.bitRate || 0) === (existing.bitRate || 0) && (s.year || 0) > (existing.year || 0))) {
|
||||
seen.set(key, s)
|
||||
}
|
||||
}
|
||||
|
||||
const songs = Array.from(seen.values()).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
artist: s.artist,
|
||||
album: s.album,
|
||||
albumId: s.albumId,
|
||||
duration: s.duration,
|
||||
track: s.track,
|
||||
year: s.year,
|
||||
coverArt: s.coverArt,
|
||||
suffix: s.suffix,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ songs })
|
||||
} catch (error) {
|
||||
console.error('Music search error:', error)
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { slskdFetch } from '@/lib/slskd'
|
||||
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { artist, title } = await request.json()
|
||||
if (!artist || !title) {
|
||||
return NextResponse.json({ error: 'artist and title required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const query = `${artist} ${title}`
|
||||
|
||||
// Start slskd search
|
||||
const searchRes = await slskdFetch('/searches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ searchText: query }),
|
||||
})
|
||||
|
||||
if (!searchRes.ok) {
|
||||
throw new Error(`slskd search returned ${searchRes.status}`)
|
||||
}
|
||||
|
||||
const { id: searchId } = await searchRes.json()
|
||||
|
||||
// Poll up to 15s (5 polls x 3s)
|
||||
let bestFile = null
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sleep(3000)
|
||||
|
||||
const res = await slskdFetch(`/searches/${searchId}`)
|
||||
if (!res.ok) continue
|
||||
|
||||
const data = await res.json()
|
||||
const responses: SlskdRawResponse[] = (data.responses || [])
|
||||
.filter((r: SlskdRawResponse) => r.files?.length > 0)
|
||||
|
||||
const files = extractBestFiles(responses, 1)
|
||||
if (files.length > 0) {
|
||||
bestFile = files[0]
|
||||
if (data.state === 'Completed' || data.state === 'TimedOut') break
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestFile) {
|
||||
return NextResponse.json({ success: false, searchId, error: 'No results found' })
|
||||
}
|
||||
|
||||
// Trigger download
|
||||
const dlRes = await slskdFetch(
|
||||
`/transfers/downloads/${encodeURIComponent(bestFile.bestPeer.username)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
filename: bestFile.filename,
|
||||
size: bestFile.size,
|
||||
}]),
|
||||
}
|
||||
)
|
||||
|
||||
if (!dlRes.ok) {
|
||||
throw new Error(`slskd download returned ${dlRes.status}`)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
searchId,
|
||||
filename: bestFile.displayName,
|
||||
peer: bestFile.bestPeer.username,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Auto-download error:', error)
|
||||
return NextResponse.json({ error: 'Auto-download failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { slskdFetch } from '@/lib/slskd'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { username, files } = await request.json()
|
||||
if (!username || !files?.length) {
|
||||
return NextResponse.json({ error: 'username and files required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await slskdFetch(`/transfers/downloads/${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(files.map((f: { filename: string; size: number }) => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
}))),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`slskd download returned ${res.status}`)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Soulseek download error:', error)
|
||||
return NextResponse.json({ error: 'Download request failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { slskdFetch } from '@/lib/slskd'
|
||||
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ searchId: string }> }
|
||||
) {
|
||||
const { searchId } = await params
|
||||
|
||||
try {
|
||||
const res = await slskdFetch(`/searches/${searchId}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`slskd results returned ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const isComplete = data.state === 'Completed' || data.state === 'TimedOut'
|
||||
|
||||
const responses: SlskdRawResponse[] = (data.responses || [])
|
||||
.filter((r: SlskdRawResponse) => r.files?.length > 0)
|
||||
|
||||
const files = extractBestFiles(responses)
|
||||
|
||||
return NextResponse.json({ files, isComplete })
|
||||
} catch (error) {
|
||||
console.error('Soulseek results error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get results' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { slskdFetch } from '@/lib/slskd'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { query } = await request.json()
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: 'query required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await slskdFetch('/searches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ searchText: query }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`slskd search returned ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ searchId: data.id })
|
||||
} catch (error) {
|
||||
console.error('Soulseek search error:', error)
|
||||
return NextResponse.json({ error: 'Soulseek search failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { navidromeFetch } from '@/lib/navidrome'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const res = await navidromeFetch('stream.view', { id })
|
||||
const contentType = res.headers.get('content-type') || 'audio/mpeg'
|
||||
const contentLength = res.headers.get('content-length')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}
|
||||
if (contentLength) headers['Content-Length'] = contentLength
|
||||
|
||||
return new Response(res.body, { headers })
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error)
|
||||
return new Response('Stream failed', { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://radio.garden/',
|
||||
}
|
||||
|
||||
interface RGChannelItem {
|
||||
page: {
|
||||
url: string
|
||||
title: string
|
||||
place: { id: string; title: string }
|
||||
country: { id: string; title: string }
|
||||
website?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface RGChannelsResponse {
|
||||
data: {
|
||||
title: string
|
||||
content: Array<{ items: RGChannelItem[] }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlimChannel {
|
||||
id: string
|
||||
title: string
|
||||
placeTitle: string
|
||||
country: string
|
||||
website?: string
|
||||
}
|
||||
|
||||
// Simple LRU-ish cache: map with max 500 entries
|
||||
const channelCache = new Map<string, { data: SlimChannel[]; fetchedAt: number }>()
|
||||
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
||||
const MAX_CACHE = 500
|
||||
|
||||
function extractChannelId(url: string): string {
|
||||
// url format: "/listen/kcsm/HQQcSxCf"
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ placeId: string }> }
|
||||
) {
|
||||
const { placeId } = await params
|
||||
|
||||
try {
|
||||
const cached = channelCache.get(placeId)
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
|
||||
return NextResponse.json(cached.data)
|
||||
}
|
||||
|
||||
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/page/${placeId}/channels`, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
|
||||
|
||||
const json: RGChannelsResponse = await res.json()
|
||||
|
||||
const channels: SlimChannel[] = (json.data.content?.[0]?.items || []).map((item) => ({
|
||||
id: extractChannelId(item.page.url),
|
||||
title: item.page.title,
|
||||
placeTitle: item.page.place.title,
|
||||
country: item.page.country.title,
|
||||
website: item.page.website,
|
||||
}))
|
||||
|
||||
// Evict oldest if over limit
|
||||
if (channelCache.size >= MAX_CACHE) {
|
||||
const oldest = channelCache.keys().next().value
|
||||
if (oldest) channelCache.delete(oldest)
|
||||
}
|
||||
channelCache.set(placeId, { data: channels, fetchedAt: Date.now() })
|
||||
|
||||
return NextResponse.json(channels)
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch channels for ${placeId}:`, error)
|
||||
const cached = channelCache.get(placeId)
|
||||
if (cached) return NextResponse.json(cached.data)
|
||||
return NextResponse.json({ error: 'Failed to load channels' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://radio.garden/',
|
||||
}
|
||||
|
||||
interface RGPlace {
|
||||
id: string
|
||||
title: string
|
||||
country: string
|
||||
geo: [number, number] // [lng, lat]
|
||||
size: number
|
||||
}
|
||||
|
||||
interface RGPlacesResponse {
|
||||
data: { list: RGPlace[] }
|
||||
}
|
||||
|
||||
export interface SlimPlace {
|
||||
id: string
|
||||
title: string
|
||||
country: string
|
||||
lat: number
|
||||
lng: number
|
||||
size: number
|
||||
}
|
||||
|
||||
let cache: { data: SlimPlace[]; fetchedAt: number } | null = null
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
|
||||
return NextResponse.json(cache.data)
|
||||
}
|
||||
|
||||
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/places`, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
|
||||
|
||||
const json: RGPlacesResponse = await res.json()
|
||||
|
||||
const places: SlimPlace[] = json.data.list.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
country: p.country,
|
||||
lat: p.geo[1],
|
||||
lng: p.geo[0],
|
||||
size: p.size,
|
||||
}))
|
||||
|
||||
cache = { data: places, fetchedAt: Date.now() }
|
||||
return NextResponse.json(places)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch radio places:', error)
|
||||
if (cache) return NextResponse.json(cache.data)
|
||||
return NextResponse.json({ error: 'Failed to load radio stations' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://radio.garden/',
|
||||
}
|
||||
|
||||
interface RGSearchHit {
|
||||
_source: {
|
||||
code: string
|
||||
type: 'channel' | 'place' | 'country'
|
||||
page: {
|
||||
url: string
|
||||
title: string
|
||||
place?: { id: string; title: string }
|
||||
country?: { id: string; title: string }
|
||||
subtitle?: string
|
||||
}
|
||||
}
|
||||
_score: number
|
||||
}
|
||||
|
||||
interface RGSearchResponse {
|
||||
hits: { hits: RGSearchHit[] }
|
||||
}
|
||||
|
||||
function extractId(url: string): string {
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export interface SearchStation {
|
||||
id: string
|
||||
title: string
|
||||
placeId: string
|
||||
placeTitle: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface SearchPlace {
|
||||
id: string
|
||||
title: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const q = searchParams.get('q')
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
return NextResponse.json({ stations: [], places: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${RADIO_GARDEN_API}/search?q=${encodeURIComponent(q)}`, {
|
||||
headers: HEADERS,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Radio Garden search returned ${res.status}`)
|
||||
|
||||
const json: RGSearchResponse = await res.json()
|
||||
|
||||
const stations: SearchStation[] = []
|
||||
const places: SearchPlace[] = []
|
||||
|
||||
for (const hit of json.hits.hits) {
|
||||
const src = hit._source
|
||||
if (src.type === 'channel') {
|
||||
stations.push({
|
||||
id: extractId(src.page.url),
|
||||
title: src.page.title,
|
||||
placeId: src.page.place?.id || '',
|
||||
placeTitle: src.page.place?.title || '',
|
||||
country: src.page.country?.title || src.code,
|
||||
})
|
||||
} else if (src.type === 'place') {
|
||||
places.push({
|
||||
id: extractId(src.page.url),
|
||||
title: src.page.title,
|
||||
country: src.page.country?.title || src.code,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ stations, places })
|
||||
} catch (error) {
|
||||
console.error('Radio search error:', error)
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
const RADIO_GARDEN_API = 'https://radio.garden/api'
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://radio.garden/',
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ channelId: string }> }
|
||||
) {
|
||||
const { channelId } = await params
|
||||
|
||||
try {
|
||||
// Follow the 302 redirect to get the actual stream URL
|
||||
const res = await fetch(
|
||||
`${RADIO_GARDEN_API}/ara/content/listen/${channelId}/channel.mp3`,
|
||||
{
|
||||
headers: HEADERS,
|
||||
redirect: 'manual', // Don't auto-follow — we want the Location header
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.status === 302) {
|
||||
const streamUrl = res.headers.get('location')
|
||||
if (streamUrl) {
|
||||
return NextResponse.json({ url: streamUrl })
|
||||
}
|
||||
}
|
||||
|
||||
// Some stations return 301 or other redirects
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const streamUrl = res.headers.get('location')
|
||||
if (streamUrl) {
|
||||
return NextResponse.json({ url: streamUrl })
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected status ${res.status}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve stream for ${channelId}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to resolve stream URL' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, email, reason } = body
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and email are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
console.error('SMTP credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: false,
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Jefflix <${smtpUser}>`,
|
||||
to: adminEmail,
|
||||
subject: `[Jefflix] New Access Request from ${name}`,
|
||||
html: `
|
||||
<h2>New Jefflix Access Request</h2>
|
||||
<p>Someone has requested access to Jefflix:</p>
|
||||
<table style="border-collapse: collapse; margin: 20px 0;">
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Name:</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(name)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Email:</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Reason:</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(reason || 'Not provided')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Requested:</td>
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">${new Date().toLocaleString()}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>To approve this request:</p>
|
||||
<ol>
|
||||
<li>Go to <a href="https://movies.jefflix.lol">Jellyfin Dashboard</a></li>
|
||||
<li>Navigate to Dashboard → Users → Add User</li>
|
||||
<li>Create an account for ${escapeHtml(name)} (${escapeHtml(email)})</li>
|
||||
<li>Reply to this email to let them know their account is ready</li>
|
||||
</ol>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||
<p style="color: #666; font-size: 12px;">This is an automated message from Jefflix.</p>
|
||||
`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Request access error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { createApproveToken } from '@/lib/token'
|
||||
|
||||
interface ChannelSelection {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, channels } = body as { email: string; channels: ChannelSelection[] }
|
||||
|
||||
// Validate email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A valid email is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate channels
|
||||
if (!Array.isArray(channels) || channels.length === 0 || channels.length > 20) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select between 1 and 20 channels' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
for (const ch of channels) {
|
||||
if (!ch.id || !ch.name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid channel data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
const smtpPass = process.env.SMTP_PASS
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
console.error('SMTP credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: false,
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
const channelListHtml = channels
|
||||
.map(
|
||||
(ch) =>
|
||||
`<li><strong>${escapeHtml(ch.name)}</strong> — <code>${escapeHtml(ch.id)}</code>` +
|
||||
(ch.country ? ` (${escapeHtml(ch.country)})` : '') +
|
||||
(ch.categories.length > 0 ? ` [${ch.categories.map(escapeHtml).join(', ')}]` : '') +
|
||||
`</li>`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const subject =
|
||||
channels.length === 1
|
||||
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
|
||||
: `[Jefflix] Channel Request: ${channels.length} channels`
|
||||
|
||||
// Generate approve token for one-click activation
|
||||
const approveToken = createApproveToken(
|
||||
channels.map((ch) => ({ id: ch.id, name: ch.name })),
|
||||
email,
|
||||
)
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://jefflix.lol'
|
||||
const approveUrl = `${baseUrl}/api/approve-channels?token=${approveToken}`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Jefflix <${smtpUser}>`,
|
||||
to: adminEmail,
|
||||
subject,
|
||||
html: `
|
||||
<h2>New Channel Request</h2>
|
||||
<p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${approveUrl}" style="display: inline-block; background: #22c55e; color: #fff; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-size: 16px; font-weight: 600;">
|
||||
✅ Approve & Add Channels
|
||||
</a>
|
||||
</div>
|
||||
<p style="text-align: center; color: #888; font-size: 12px;">One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.</p>
|
||||
|
||||
<ul style="line-height: 1.8;">
|
||||
${channelListHtml}
|
||||
</ul>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||
<p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
|
||||
`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Channel request error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
const ACCESS_CODE = process.env.ACCESS_CODE || "42069"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { code } = await request.json()
|
||||
|
||||
if (code !== ACCESS_CODE) {
|
||||
return NextResponse.json({ error: "Invalid code" }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
response.cookies.set("jefflix-access", "granted", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
path: "/",
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
// BUILD_ID is set at build time by Next.js standalone output.
|
||||
// Falls back to a startup timestamp so every restart counts as a new version.
|
||||
const VERSION = process.env.BUILD_ID || process.env.NEXT_BUILD_ID || Date.now().toString()
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ version: VERSION },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||
|
||||
export default function GatePage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
const res = await fetch("/api/verify-code", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
} else {
|
||||
setError("Wrong code. Try again.")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50/80 via-background/90 to-green-50/80" />
|
||||
<div className="relative w-full max-w-md mx-auto px-4">
|
||||
<div className="text-center space-y-8">
|
||||
<JefflixLogo />
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Enter the access code to continue
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Access code"
|
||||
autoFocus
|
||||
className="w-full text-center text-2xl tracking-widest px-4 py-3 rounded-lg border-2 border-border bg-card focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-destructive font-medium">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !code}
|
||||
className="w-full py-3 px-6 rounded-lg bg-primary text-primary-foreground font-bold text-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Checking..." : "Enter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { MusicProvider } from "@/components/music/music-provider"
|
||||
import { MiniPlayer } from "@/components/music/mini-player"
|
||||
import { UpdateBanner } from "@/components/update-banner"
|
||||
import { OfflineProvider } from "@/lib/stores/offline"
|
||||
import { ServiceWorkerRegister } from "@/components/sw-register"
|
||||
import "./globals.css"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
|
|
@ -18,9 +22,10 @@ const _permanentMarker = Permanent_Marker({
|
|||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://jefflix.com"),
|
||||
title: "Jefflix - Seize the Streams of Production",
|
||||
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
|
||||
generator: "v0.app",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
|
|
@ -38,6 +43,28 @@ export const metadata: Metadata = {
|
|||
],
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://jefflix.com",
|
||||
title: "Jefflix - Seize the Streams of Production",
|
||||
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
|
||||
siteName: "Jefflix",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Jefflix - Seize the Streams of Production",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Jefflix - Seize the Streams of Production",
|
||||
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
|
||||
images: ["/og-image.jpg"],
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -48,8 +75,14 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
||||
<body className={`font-sans antialiased`}>
|
||||
{children}
|
||||
<Analytics />
|
||||
<ServiceWorkerRegister />
|
||||
<UpdateBanner />
|
||||
<MusicProvider>
|
||||
<OfflineProvider>
|
||||
{children}
|
||||
<MiniPlayer />
|
||||
</OfflineProvider>
|
||||
</MusicProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,492 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||
import { SongRow } from '@/components/music/search-results'
|
||||
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
|
||||
import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
|
||||
import { useOffline } from '@/lib/stores/offline'
|
||||
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users, DownloadCloud, CheckCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Playlist {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
interface SlskdFile {
|
||||
displayName: string
|
||||
filename: string
|
||||
size: number
|
||||
bitRate: number
|
||||
length: number
|
||||
bestPeer: {
|
||||
username: string
|
||||
freeSlots: number
|
||||
speed: number
|
||||
}
|
||||
peerCount: number
|
||||
}
|
||||
|
||||
export default function MusicPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<MusicPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function MusicPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const { state } = useMusicPlayer()
|
||||
const { offlineIds, download: downloadTrack } = useOffline()
|
||||
const [query, setQuery] = useState(() => searchParams.get('q') || '')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [songs, setSongs] = useState<Track[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Playlist browsing state
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(true)
|
||||
const [expandedPlaylist, setExpandedPlaylist] = useState<string | null>(null)
|
||||
const [playlistSongs, setPlaylistSongs] = useState<Track[]>([])
|
||||
const [playlistSongsLoading, setPlaylistSongsLoading] = useState(false)
|
||||
|
||||
// Soulseek state
|
||||
const [slskMode, setSlskMode] = useState(false)
|
||||
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
|
||||
const [slskResults, setSlskResults] = useState<SlskdFile[]>([])
|
||||
const [slskSearching, setSlskSearching] = useState(false)
|
||||
const [downloading, setDownloading] = useState<string | null>(null)
|
||||
const pollRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Fetch playlists on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/music/playlists')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPlaylists(d.playlists || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setPlaylistsLoading(false))
|
||||
}, [])
|
||||
|
||||
const togglePlaylist = async (id: string) => {
|
||||
if (expandedPlaylist === id) {
|
||||
setExpandedPlaylist(null)
|
||||
setPlaylistSongs([])
|
||||
return
|
||||
}
|
||||
setExpandedPlaylist(id)
|
||||
setPlaylistSongsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/music/playlist/${id}`)
|
||||
const d = await res.json()
|
||||
setPlaylistSongs(d.songs || [])
|
||||
} catch {
|
||||
setPlaylistSongs([])
|
||||
}
|
||||
setPlaylistSongsLoading(false)
|
||||
}
|
||||
|
||||
// Debounced Navidrome search
|
||||
useEffect(() => {
|
||||
if (slskMode) return
|
||||
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query, slskMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery || debouncedQuery.length < 2 || slskMode) {
|
||||
setSongs([])
|
||||
return
|
||||
}
|
||||
|
||||
setSearching(true)
|
||||
setSearchError('')
|
||||
fetch(`/api/music/search?q=${encodeURIComponent(debouncedQuery)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.error) throw new Error(d.error)
|
||||
setSongs(d.songs || [])
|
||||
})
|
||||
.catch((e) => setSearchError(e.message))
|
||||
.finally(() => setSearching(false))
|
||||
}, [debouncedQuery, slskMode])
|
||||
|
||||
// Cleanup slskd polling on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (pollRef.current) clearTimeout(pollRef.current) }
|
||||
}, [])
|
||||
|
||||
const searchSoulseek = async () => {
|
||||
setSlskMode(true)
|
||||
setSlskSearching(true)
|
||||
setSlskResults([])
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/music/slskd/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: query || debouncedQuery }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.error) throw new Error(d.error)
|
||||
setSlskSearchId(d.searchId)
|
||||
pollSlskResults(d.searchId)
|
||||
} catch {
|
||||
setSlskSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pollSlskResults = (searchId: string) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/music/slskd/results/${searchId}`)
|
||||
const d = await res.json()
|
||||
setSlskResults(d.files || [])
|
||||
if (!d.isComplete) {
|
||||
pollRef.current = setTimeout(poll, 2000)
|
||||
} else {
|
||||
setSlskSearching(false)
|
||||
}
|
||||
} catch {
|
||||
setSlskSearching(false)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
const triggerDownload = async (file: SlskdFile) => {
|
||||
const key = `${file.bestPeer.username}:${file.filename}`
|
||||
setDownloading(key)
|
||||
try {
|
||||
await fetch('/api/music/slskd/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: file.bestPeer.username,
|
||||
files: [{ filename: file.filename, size: file.size }],
|
||||
}),
|
||||
})
|
||||
} catch {}
|
||||
setDownloading(null)
|
||||
}
|
||||
|
||||
const exitSlsk = () => {
|
||||
setSlskMode(false)
|
||||
setSlskSearchId(null)
|
||||
setSlskResults([])
|
||||
setSlskSearching(false)
|
||||
if (pollRef.current) clearTimeout(pollRef.current)
|
||||
}
|
||||
|
||||
const hasPlayer = !!state.currentTrack
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-background ${hasPlayer ? 'pb-20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="inline-block">
|
||||
<JefflixLogo size="small" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/offline">
|
||||
<Button variant="ghost" size="sm">
|
||||
<WifiOff className="h-4 w-4 mr-1.5" />
|
||||
Offline
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||
Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="container mx-auto px-4 py-12 md:py-16">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Hero */}
|
||||
<div className="text-center space-y-4 mb-8">
|
||||
<div className="inline-block p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||
<Music className="h-10 w-10 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-marker">Music</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Search the library, play songs, and manage playlists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); if (slskMode) exitSlsk() }}
|
||||
className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Search songs, artists, albums..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Soulseek mode toggle */}
|
||||
{slskMode && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge className="bg-yellow-600 text-white">Soulseek</Badge>
|
||||
<span className="text-sm text-muted-foreground">Searching peer-to-peer network</span>
|
||||
<button onClick={exitSlsk} className="text-sm text-primary hover:underline ml-auto">
|
||||
Back to Library
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navidrome Results */}
|
||||
{!slskMode && (
|
||||
<>
|
||||
{searching && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchError && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{searchError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && songs.length > 0 && (
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{songs.map((song, i) => (
|
||||
<SongRow key={song.id} song={song} songs={songs} index={i} showDownload />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && debouncedQuery.length >= 2 && songs.length === 0 && !searchError && (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
No results for “{debouncedQuery}” in the library
|
||||
</p>
|
||||
<Button
|
||||
onClick={searchSoulseek}
|
||||
className="bg-yellow-600 hover:bg-yellow-700 text-white"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
Search Soulseek
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length > 0 && query.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* MusicBrainz Discovery */}
|
||||
{debouncedQuery.length >= 2 && (
|
||||
<MusicBrainzResultsSection query={debouncedQuery} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Soulseek Results */}
|
||||
{slskMode && (
|
||||
<>
|
||||
{slskSearching && slskResults.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-yellow-600" />
|
||||
<p className="text-sm text-muted-foreground">Searching peer-to-peer network...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slskResults.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{slskSearching && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Still searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{slskResults.map((file) => {
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(1)
|
||||
const key = `${file.bestPeer.username}:${file.filename}`
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{file.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{sizeMB} MB
|
||||
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
|
||||
{' · '}{file.bestPeer.username}
|
||||
</div>
|
||||
</div>
|
||||
{file.peerCount > 1 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex-shrink-0">
|
||||
<Users className="h-3 w-3 mr-0.5" />
|
||||
{file.peerCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => triggerDownload(file)}
|
||||
disabled={downloading === key}
|
||||
>
|
||||
{downloading === key ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!slskSearching && slskResults.length === 0 && slskSearchId && (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No results found on Soulseek
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Playlists - shown when not searching */}
|
||||
{!slskMode && !debouncedQuery && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 mb-4">
|
||||
<ListMusic className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
Playlists
|
||||
</h2>
|
||||
|
||||
{playlistsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No playlists yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{playlists.map((p) => {
|
||||
// Check how many songs in this playlist are already offline
|
||||
const allOffline = expandedPlaylist === p.id && playlistSongs.length > 0 &&
|
||||
playlistSongs.every((s) => offlineIds.has(s.id))
|
||||
|
||||
return (
|
||||
<div key={p.id}>
|
||||
<div className="flex items-center hover:bg-muted/50 transition-colors">
|
||||
<button
|
||||
onClick={() => togglePlaylist(p.id)}
|
||||
className="flex-1 flex items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
|
||||
{p.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${p.coverArt}?size=96`}
|
||||
alt={p.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ListMusic className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
|
||||
</div>
|
||||
{expandedPlaylist === p.id ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{/* Download entire playlist */}
|
||||
{expandedPlaylist === p.id && playlistSongs.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
playlistSongs.forEach((s) => {
|
||||
if (!offlineIds.has(s.id)) downloadTrack(s)
|
||||
})
|
||||
}}
|
||||
className={`p-2 mr-2 rounded-full transition-colors flex-shrink-0 ${
|
||||
allOffline
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
title={allOffline ? 'All songs downloaded' : 'Download all for offline'}
|
||||
disabled={allOffline}
|
||||
>
|
||||
{allOffline ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<DownloadCloud className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedPlaylist === p.id && (
|
||||
<div className="bg-muted/30 border-t border-border">
|
||||
{playlistSongsLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : playlistSongs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Empty playlist
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{playlistSongs.map((song, i) => (
|
||||
<SongRow key={song.id} song={song} songs={playlistSongs} index={i} showDownload />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-12 p-6 bg-muted/50 rounded-lg space-y-3">
|
||||
<h3 className="font-bold mb-2">How does this work?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This searches your Navidrome music library. Songs play directly in the browser through a
|
||||
persistent audio player. Can't find what you're looking for? Search Soulseek to find
|
||||
and download music from the peer-to-peer network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||
import { SongRow } from '@/components/music/search-results'
|
||||
import { useMusicPlayer } from '@/components/music/music-provider'
|
||||
import { useOffline } from '@/lib/stores/offline'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
ListPlus,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export default function OfflinePage() {
|
||||
const { state, playTrack, addAllToQueue } = useMusicPlayer()
|
||||
const {
|
||||
offlineTracks,
|
||||
queue,
|
||||
activeDownloadId,
|
||||
storageUsed,
|
||||
clearAll,
|
||||
sync,
|
||||
loading,
|
||||
} = useOffline()
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const hasPlayer = !!state.currentTrack
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
await sync()
|
||||
setSyncing(false)
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm('Remove all downloaded songs? They can be re-downloaded later.')) return
|
||||
setClearing(true)
|
||||
await clearAll()
|
||||
setClearing(false)
|
||||
}
|
||||
|
||||
const playAllOffline = () => {
|
||||
if (offlineTracks.length > 0) {
|
||||
playTrack(offlineTracks[0], offlineTracks, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-background ${hasPlayer ? 'pb-20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<Link href="/" className="inline-block">
|
||||
<JefflixLogo size="small" />
|
||||
</Link>
|
||||
<Link href="/music">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||
Music
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-12 md:py-16">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Hero */}
|
||||
<div className="text-center space-y-4 mb-8">
|
||||
<div className="inline-block p-4 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||
<WifiOff className="h-10 w-10 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-marker">Offline Library</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Songs downloaded for offline playback. Syncs across all your devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats + Actions */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
{formatBytes(storageUsed)} used
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
|
||||
<Download className="h-4 w-4" />
|
||||
{offlineTracks.length} songs
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing}>
|
||||
{syncing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
Sync
|
||||
</Button>
|
||||
{offlineTracks.length > 0 && (
|
||||
<>
|
||||
{state.currentTrack ? (
|
||||
<Button size="sm" variant="outline" onClick={() => addAllToQueue(offlineTracks)}>
|
||||
<ListPlus className="h-4 w-4 mr-1.5" />
|
||||
Add All to Queue
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={playAllOffline}>
|
||||
<Play className="h-4 w-4 mr-1.5" />
|
||||
Play All
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" size="sm" onClick={handleClearAll} disabled={clearing}>
|
||||
{clearing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <Trash2 className="h-4 w-4 mr-1.5" />}
|
||||
Clear All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download queue */}
|
||||
{queue.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
Downloading ({queue.length} remaining)
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{queue.slice(0, 5).map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2">
|
||||
{t.id === activeDownloadId && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
<span className="truncate">{t.title} — {t.artist}</span>
|
||||
</div>
|
||||
))}
|
||||
{queue.length > 5 && (
|
||||
<div className="text-xs">...and {queue.length - 5} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Songs list */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
||||
</div>
|
||||
) : offlineTracks.length === 0 ? (
|
||||
<div className="text-center py-12 space-y-4">
|
||||
<WifiOff className="h-12 w-12 mx-auto text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground">
|
||||
No songs downloaded yet. Tap the download icon on any song to save it for offline.
|
||||
</p>
|
||||
<Link href="/music">
|
||||
<Button variant="outline">
|
||||
Browse Music
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{offlineTracks.map((song, i) => (
|
||||
<SongRow key={song.id} song={song} songs={offlineTracks} index={i} showDownload />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
app/page.tsx
118
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette } from "lucide-react"
|
||||
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload, Waves } from "lucide-react"
|
||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||
|
||||
export default function JefflixPage() {
|
||||
|
|
@ -48,34 +48,80 @@ export default function JefflixPage() {
|
|||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="https://movies.jefflix.lol">
|
||||
<Film className="mr-2 h-5 w-5" />
|
||||
Movies
|
||||
<a href="https://requests.jefflix.lol">
|
||||
<ListPlus className="mr-2 h-5 w-5" />
|
||||
Request a Show or Movie
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="https://shows.jefflix.lol">
|
||||
<Tv className="mr-2 h-5 w-5" />
|
||||
Shows
|
||||
<a href="https://movies.jefflix.lol">
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
Watch a Show or Movie
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-green-600 hover:bg-green-700 text-white"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
>
|
||||
<a href="https://music.jefflix.lol">
|
||||
<a href="https://upload.jefflix.lol">
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
Upload Shows or Movies
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="https://tv.jefflix.lol">
|
||||
<Tv className="mr-2 h-5 w-5" />
|
||||
Watch Live TV
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="/request-channel">
|
||||
<Radio className="mr-2 h-5 w-5" />
|
||||
Request a Channel
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="/music">
|
||||
<Music className="mr-2 h-5 w-5" />
|
||||
Music
|
||||
Listen to Music
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white"
|
||||
variant="default"
|
||||
>
|
||||
<a href="/radio">
|
||||
<Waves className="mr-2 h-5 w-5" />
|
||||
Listen to Radio
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -204,20 +250,20 @@ export default function JefflixPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-6">
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white">
|
||||
<a href="https://movies.jefflix.lol">
|
||||
<Film className="mr-2 h-5 w-5" />
|
||||
Movies
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<a href="https://requests.jefflix.lol">
|
||||
<ListPlus className="mr-2 h-5 w-5" />
|
||||
Request a Show or Movie
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<a href="https://shows.jefflix.lol">
|
||||
<Tv className="mr-2 h-5 w-5" />
|
||||
Shows
|
||||
<a href="https://movies.jefflix.lol">
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
Watch a Show or Movie
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -225,9 +271,35 @@ export default function JefflixPage() {
|
|||
size="lg"
|
||||
className="text-lg px-8 py-6 font-bold bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<a href="https://music.jefflix.lol">
|
||||
<a href="https://upload.jefflix.lol">
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
Upload Shows or Movies
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white">
|
||||
<a href="https://tv.jefflix.lol">
|
||||
<Tv className="mr-2 h-5 w-5" />
|
||||
Watch Live TV
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-cyan-600 hover:bg-cyan-700 text-white">
|
||||
<a href="/request-channel">
|
||||
<Radio className="mr-2 h-5 w-5" />
|
||||
Request a Channel
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white">
|
||||
<a href="/music">
|
||||
<Music className="mr-2 h-5 w-5" />
|
||||
Music
|
||||
Listen to Music
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white">
|
||||
<a href="/radio">
|
||||
<Waves className="mr-2 h-5 w-5" />
|
||||
Listen to Radio
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -321,7 +393,7 @@ export default function JefflixPage() {
|
|||
Powered by Jellyfin. Built on solidarity. Infrastructure for the sharing economy—starting digitally.
|
||||
</p>
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
© 2025 • Support artists • Build movements • Question everything
|
||||
© 2026 • Support artists • Build movements • Question everything
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useMusicPlayer, type RadioTrack } from '@/components/music/music-provider'
|
||||
import { GlobeLoader, type GlobePoint } from '@/components/globe/globe-loader'
|
||||
import { JefflixLogo } from '@/components/jefflix-logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Radio,
|
||||
Loader2,
|
||||
Play,
|
||||
MapPin,
|
||||
Globe2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { RadioPlace, RadioChannel } from '@/lib/radio'
|
||||
import { getPlaces, getChannels, resolveStreamUrl, searchRadio } from '@/lib/radio'
|
||||
|
||||
export default function RadioPage() {
|
||||
const { state, playTrack } = useMusicPlayer()
|
||||
|
||||
// Globe data
|
||||
const [places, setPlaces] = useState<RadioPlace[]>([])
|
||||
const [loadingPlaces, setLoadingPlaces] = useState(true)
|
||||
const [placesError, setPlacesError] = useState('')
|
||||
|
||||
// Selected place & channels
|
||||
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null)
|
||||
const [channels, setChannels] = useState<RadioChannel[]>([])
|
||||
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||
|
||||
// Search
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
stations: Array<{ id: string; title: string; placeId: string; placeTitle: string; country: string }>
|
||||
places: Array<{ id: string; title: string; country: string }>
|
||||
} | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Playing state
|
||||
const [resolvingId, setResolvingId] = useState<string | null>(null)
|
||||
|
||||
// Globe focus
|
||||
const [focusLat, setFocusLat] = useState<number | undefined>(undefined)
|
||||
const [focusLng, setFocusLng] = useState<number | undefined>(undefined)
|
||||
|
||||
// Load places on mount
|
||||
useEffect(() => {
|
||||
getPlaces()
|
||||
.then((data) => {
|
||||
setPlaces(data)
|
||||
setLoadingPlaces(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setPlacesError(err.message)
|
||||
setLoadingPlaces(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
// Execute search
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length < 2) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
searchRadio(debouncedQuery)
|
||||
.then(setSearchResults)
|
||||
.catch(() => setSearchResults(null))
|
||||
.finally(() => setSearching(false))
|
||||
}, [debouncedQuery])
|
||||
|
||||
// Convert places to globe points
|
||||
const globePoints: GlobePoint[] = places.map((p) => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
id: p.id,
|
||||
label: `${p.title}, ${p.country}`,
|
||||
color: '#f43f5e', // rose-500
|
||||
size: p.size,
|
||||
}))
|
||||
|
||||
// Handle globe point click
|
||||
const handlePointClick = useCallback((point: GlobePoint) => {
|
||||
const place = places.find((p) => p.id === point.id)
|
||||
if (!place) return
|
||||
setSelectedPlace(place)
|
||||
setFocusLat(place.lat)
|
||||
setFocusLng(place.lng)
|
||||
setLoadingChannels(true)
|
||||
getChannels(place.id)
|
||||
.then(setChannels)
|
||||
.catch(() => setChannels([]))
|
||||
.finally(() => setLoadingChannels(false))
|
||||
}, [places])
|
||||
|
||||
// Handle search place click
|
||||
const handleSearchPlaceClick = useCallback((placeId: string, placeTitle: string, country: string) => {
|
||||
// Find in places array for geo data, or just load channels
|
||||
const place = places.find((p) => p.id === placeId)
|
||||
if (place) {
|
||||
setSelectedPlace(place)
|
||||
setFocusLat(place.lat)
|
||||
setFocusLng(place.lng)
|
||||
} else {
|
||||
setSelectedPlace({ id: placeId, title: placeTitle, country, lat: 0, lng: 0, size: 1 })
|
||||
}
|
||||
setQuery('')
|
||||
setSearchResults(null)
|
||||
setLoadingChannels(true)
|
||||
getChannels(placeId)
|
||||
.then(setChannels)
|
||||
.catch(() => setChannels([]))
|
||||
.finally(() => setLoadingChannels(false))
|
||||
}, [places])
|
||||
|
||||
// Play a station
|
||||
const handlePlayStation = useCallback(async (channel: RadioChannel) => {
|
||||
setResolvingId(channel.id)
|
||||
try {
|
||||
const streamUrl = await resolveStreamUrl(channel.id)
|
||||
const radioTrack: RadioTrack = {
|
||||
type: 'radio',
|
||||
id: `radio:${channel.id}`,
|
||||
title: channel.title,
|
||||
artist: `${channel.placeTitle}, ${channel.country}`,
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
coverArt: '',
|
||||
streamUrl,
|
||||
}
|
||||
playTrack(radioTrack)
|
||||
} catch (err) {
|
||||
console.error('Failed to play station:', err)
|
||||
} finally {
|
||||
setResolvingId(null)
|
||||
}
|
||||
}, [playTrack])
|
||||
|
||||
// Play search station directly
|
||||
const handlePlaySearchStation = useCallback(async (station: { id: string; title: string; placeTitle: string; country: string }) => {
|
||||
setResolvingId(station.id)
|
||||
try {
|
||||
const streamUrl = await resolveStreamUrl(station.id)
|
||||
const radioTrack: RadioTrack = {
|
||||
type: 'radio',
|
||||
id: `radio:${station.id}`,
|
||||
title: station.title,
|
||||
artist: `${station.placeTitle}, ${station.country}`,
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
coverArt: '',
|
||||
streamUrl,
|
||||
}
|
||||
playTrack(radioTrack)
|
||||
} catch (err) {
|
||||
console.error('Failed to play station:', err)
|
||||
} finally {
|
||||
setResolvingId(null)
|
||||
}
|
||||
}, [playTrack])
|
||||
|
||||
const isPlaying = (channelId: string) =>
|
||||
state.currentTrack?.id === `radio:${channelId}` && state.isPlaying
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-background ${state.currentTrack ? 'pb-20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<Link href="/" className="flex-shrink-0">
|
||||
<JefflixLogo />
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="container mx-auto px-4 py-6 text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<Radio className="h-8 w-8 text-rose-500" />
|
||||
<h1 className="text-3xl font-bold">World Radio</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-lg mx-auto">
|
||||
Explore live radio stations from around the globe. Click a point on the globe or search for stations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="container mx-auto px-4 pb-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* Globe */}
|
||||
<div className="lg:col-span-3 relative">
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
{loadingPlaces ? (
|
||||
<div className="flex items-center justify-center h-[400px] lg:h-[550px]">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="text-sm">Loading {places.length > 0 ? `${places.length.toLocaleString()} stations` : 'stations'}...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : placesError ? (
|
||||
<div className="flex items-center justify-center h-[400px] lg:h-[550px] text-destructive">
|
||||
<p>Failed to load stations: {placesError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<GlobeLoader
|
||||
points={globePoints}
|
||||
onPointClick={handlePointClick}
|
||||
height={typeof window !== 'undefined' && window.innerWidth >= 1024 ? 550 : 400}
|
||||
autoRotate={!selectedPlace}
|
||||
focusLat={focusLat}
|
||||
focusLng={focusLng}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{places.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
<Globe2 className="h-3 w-3 inline mr-1" />
|
||||
{places.length.toLocaleString()} locations worldwide
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Station panel */}
|
||||
<div className="lg:col-span-2">
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search stations, cities, countries..."
|
||||
className="w-full pl-10 pr-10 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-rose-500/50"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setSearchResults(null) }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{searchResults && (
|
||||
<div className="rounded-lg border border-border bg-card mb-4">
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{searching ? 'Searching...' : `${searchResults.stations.length} stations, ${searchResults.places.length} places`}
|
||||
</h3>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
{/* Station results */}
|
||||
{searchResults.stations.map((station) => (
|
||||
<button
|
||||
key={station.id}
|
||||
onClick={() => handlePlaySearchStation(station)}
|
||||
disabled={resolvingId === station.id}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-rose-500/10 flex items-center justify-center">
|
||||
{resolvingId === station.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
|
||||
) : isPlaying(station.id) ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
|
||||
<div className="w-0.5 h-4 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-0.5 h-2 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 text-rose-500 ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{station.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{station.placeTitle}, {station.country}
|
||||
</div>
|
||||
</div>
|
||||
<Radio className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Place results */}
|
||||
{searchResults.places.map((place) => (
|
||||
<button
|
||||
key={place.id}
|
||||
onClick={() => handleSearchPlaceClick(place.id, place.title, place.country)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||
<MapPin className="h-3.5 w-3.5 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{place.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{place.country}</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[10px]">Place</Badge>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected place channels */}
|
||||
{selectedPlace && !searchResults && (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-rose-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold truncate">{selectedPlace.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{selectedPlace.country}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelectedPlace(null); setChannels([]) }}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{loadingChannels ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No stations found in this area
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
{channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => handlePlayStation(channel)}
|
||||
disabled={resolvingId === channel.id}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-rose-500/10 flex items-center justify-center">
|
||||
{resolvingId === channel.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
|
||||
) : isPlaying(channel.id) ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
|
||||
<div className="w-0.5 h-5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-0.5 h-2.5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
) : (
|
||||
<Play className="h-5 w-5 text-rose-500 ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{channel.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{channel.placeTitle}, {channel.country}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!selectedPlace && !searchResults && (
|
||||
<div className="rounded-lg border border-border bg-card px-6 py-12 text-center">
|
||||
<Globe2 className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
|
||||
<h3 className="font-medium mb-1">Explore World Radio</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click a point on the globe to discover radio stations, or use the search bar to find specific stations and cities.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||
import { UserPlus, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react"
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function RequestAccessPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
reason: '',
|
||||
})
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('loading')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/request-access', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to submit request')
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
} catch (error) {
|
||||
setStatus('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="inline-block p-6 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Request Submitted!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your access request has been sent. You'll receive an email once your account is ready.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This usually happens within 24-48 hours.
|
||||
</p>
|
||||
<div className="text-left bg-muted/50 rounded-lg p-6 mt-4 space-y-3">
|
||||
<h3 className="font-bold text-center">Once you're approved:</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
|
||||
<li>Request movies & shows at <a href="https://requests.jefflix.lol" className="text-blue-600 hover:underline font-medium">requests.jefflix.lol</a></li>
|
||||
<li>Watch them at <a href="https://movies.jefflix.lol" className="text-red-600 hover:underline font-medium">movies.jefflix.lol</a></li>
|
||||
<li>Both sites can be installed as apps on your phone's home screen, or use the Jellyfin app on a smart TV</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href="/">
|
||||
<Button variant="outline" className="mt-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Jefflix
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<Link href="/" className="inline-block">
|
||||
<JefflixLogo size="small" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-12 md:py-20">
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="text-center space-y-4 mb-8">
|
||||
<div className="inline-block p-4 bg-orange-100 dark:bg-orange-900/30 rounded-full">
|
||||
<UserPlus className="h-10 w-10 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-marker">Request Access</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Jefflix is a community media server. To protect the community and ensure quality access,
|
||||
we require approval for new accounts.
|
||||
</p>
|
||||
<Badge className="bg-orange-600 text-white">
|
||||
Required for Live Sports & Premium Content
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Your Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We'll send your login details to this address
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reason" className="text-sm font-medium">
|
||||
How do you know Jeff? (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="reason"
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500 min-h-[100px]"
|
||||
placeholder="Just helps me know who's joining the community..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full py-6 text-lg font-bold bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
{status === 'loading' ? 'Submitting...' : 'Request Access'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
|
||||
<h3 className="font-bold mb-2">What happens next?</h3>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Your request is sent to the admin for review</li>
|
||||
<li>Once approved, you'll get an email with your login details</li>
|
||||
<li>Request movies & shows at <a href="https://requests.jefflix.lol" className="text-blue-600 hover:underline font-medium">requests.jefflix.lol</a></li>
|
||||
<li>Watch them at <a href="https://movies.jefflix.lol" className="text-red-600 hover:underline font-medium">movies.jefflix.lol</a></li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground border-t border-border pt-3">
|
||||
Both sites can be installed as apps on your phone's home screen. On a smart TV, use the Jellyfin app and connect to movies.jefflix.lol.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||
import { GlobeLoader, type GlobePoint } from "@/components/globe/globe-loader"
|
||||
import { countryToLatLng } from "@/lib/country-centroids"
|
||||
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2, Globe2, List } from "lucide-react"
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
export default function RequestChannelPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [selected, setSelected] = useState<Channel[]>([])
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Globe view toggle
|
||||
const [view, setView] = useState<'search' | 'globe'>('search')
|
||||
const [globeCountry, setGlobeCountry] = useState<string | null>(null)
|
||||
|
||||
// Build globe points from channels (aggregate by country)
|
||||
const globePoints: GlobePoint[] = useMemo(() => {
|
||||
const countryMap = new Map<string, { count: number; coords: [number, number] }>()
|
||||
for (const ch of channels) {
|
||||
if (!ch.country) continue
|
||||
const existing = countryMap.get(ch.country)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
const coords = countryToLatLng(ch.country)
|
||||
if (coords) {
|
||||
countryMap.set(ch.country, { count: 1, coords })
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(countryMap.entries()).map(([country, { count, coords }]) => ({
|
||||
lat: coords[0],
|
||||
lng: coords[1],
|
||||
id: country,
|
||||
label: `${country} (${count} channels)`,
|
||||
color: '#06b6d4', // cyan-500
|
||||
size: Math.max(1, Math.min(8, Math.log2(count + 1) * 2)),
|
||||
}))
|
||||
}, [channels])
|
||||
|
||||
// Channels filtered for globe-selected country
|
||||
const globeFilteredChannels = useMemo(() => {
|
||||
if (!globeCountry) return []
|
||||
return channels.filter((ch) => ch.country === globeCountry)
|
||||
}, [channels, globeCountry])
|
||||
|
||||
const handleGlobePointClick = useCallback((point: GlobePoint) => {
|
||||
setGlobeCountry(point.id) // id = country name
|
||||
}, [])
|
||||
|
||||
// Fetch channel list on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load channels')
|
||||
return res.json()
|
||||
})
|
||||
.then((data: Channel[]) => {
|
||||
setChannels(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoadError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 200)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
const results = debouncedQuery.length < 2
|
||||
? []
|
||||
: channels.filter((ch) => {
|
||||
const q = debouncedQuery.toLowerCase()
|
||||
return (
|
||||
ch.name.toLowerCase().includes(q) ||
|
||||
ch.country.toLowerCase().includes(q) ||
|
||||
ch.categories.some((c) => c.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
const totalMatches = results.length
|
||||
const displayResults = results.slice(0, 50)
|
||||
|
||||
const toggleChannel = useCallback((ch: Channel) => {
|
||||
setSelected((prev) =>
|
||||
prev.some((s) => s.id === ch.id)
|
||||
? prev.filter((s) => s.id !== ch.id)
|
||||
: prev.length < 20
|
||||
? [...prev, ch]
|
||||
: prev
|
||||
)
|
||||
}, [])
|
||||
|
||||
const removeChannel = useCallback((id: string) => {
|
||||
setSelected((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selected.length === 0) return
|
||||
setStatus('submitting')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/request-channel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
channels: selected.map(({ id, name, country, categories }) => ({
|
||||
id, name, country, categories,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to submit request')
|
||||
setStatus('success')
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Something went wrong')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="inline-block p-6 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Request Submitted!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
We'll look into adding {selected.length === 1 ? 'this channel' : `these ${selected.length} channels`} and
|
||||
let you know once {selected.length === 1 ? "it's" : "they're"} available.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Most channels are added within a few days.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<Button variant="outline" className="mt-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Jefflix
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<Link href="/" className="inline-block">
|
||||
<JefflixLogo size="small" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-12 md:py-20">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center space-y-4 mb-8">
|
||||
<div className="inline-block p-4 bg-cyan-100 dark:bg-cyan-900/30 rounded-full">
|
||||
<Search className="h-10 w-10 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-marker">Request a Channel</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.
|
||||
</p>
|
||||
|
||||
{/* View toggle */}
|
||||
{!loading && !loadError && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant={view === 'search' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setView('search')}
|
||||
className={view === 'search' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
|
||||
>
|
||||
<List className="h-4 w-4 mr-1.5" />
|
||||
Search
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'globe' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setView('globe')}
|
||||
className={view === 'globe' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
|
||||
>
|
||||
<Globe2 className="h-4 w-4 mr-1.5" />
|
||||
Globe
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-600" />
|
||||
<p className="text-muted-foreground">Loading channel catalog...</p>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<p className="text-muted-foreground">{loadError}</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : view === 'globe' ? (
|
||||
/* Globe View */
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<GlobeLoader
|
||||
points={globePoints}
|
||||
onPointClick={handleGlobePointClick}
|
||||
height={450}
|
||||
autoRotate={!globeCountry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected chips (shared with search view) */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map((ch) => (
|
||||
<Badge
|
||||
key={ch.id}
|
||||
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
|
||||
onClick={() => removeChannel(ch.id)}
|
||||
>
|
||||
{ch.name}
|
||||
<X className="h-3.5 w-3.5 ml-1" />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country channel list */}
|
||||
{globeCountry && (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{globeCountry}</h3>
|
||||
<p className="text-xs text-muted-foreground">{globeFilteredChannels.length} channels</p>
|
||||
</div>
|
||||
<button onClick={() => setGlobeCountry(null)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto divide-y divide-border">
|
||||
{globeFilteredChannels.map((ch) => {
|
||||
const isSelected = selected.some((s) => s.id === ch.id)
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
type="button"
|
||||
onClick={() => toggleChannel(ch)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
|
||||
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{ch.name}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ch.categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="text-xs">{cat}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!globeCountry && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Click a point on the globe to browse channels by country
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Email + Submit (same as search view) */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="globe-email" className="text-sm font-medium">Your Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="globe-email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'submitting' || selected.length === 0}
|
||||
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
|
||||
>
|
||||
{status === 'submitting'
|
||||
? 'Submitting...'
|
||||
: selected.length === 0
|
||||
? 'Select channels to request'
|
||||
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="Search by channel name, country, or category..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map((ch) => (
|
||||
<Badge
|
||||
key={ch.id}
|
||||
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
|
||||
onClick={() => removeChannel(ch.id)}
|
||||
>
|
||||
{ch.name}
|
||||
<X className="h-3.5 w-3.5 ml-1" />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery.length >= 2 && (
|
||||
<div className="border border-border rounded-lg divide-y divide-border max-h-[400px] overflow-y-auto">
|
||||
{displayResults.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
No channels found for "{debouncedQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{displayResults.map((ch) => {
|
||||
const isSelected = selected.some((s) => s.id === ch.id)
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
type="button"
|
||||
onClick={() => toggleChannel(ch)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
|
||||
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{ch.name}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ch.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ch.country}
|
||||
</Badge>
|
||||
)}
|
||||
{ch.categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="text-xs">
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{totalMatches > 50 && (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground bg-muted/30">
|
||||
{totalMatches - 50} more — refine your search
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debouncedQuery.length > 0 && debouncedQuery.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Your Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We'll let you know when the channels are added
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'submitting' || selected.length === 0}
|
||||
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
|
||||
>
|
||||
{status === 'submitting'
|
||||
? 'Submitting...'
|
||||
: selected.length === 0
|
||||
? 'Select channels to request'
|
||||
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
|
||||
<h3 className="font-bold mb-2">How does this work?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This catalog comes from <a href="https://iptv-org.github.io" className="text-cyan-600 hover:underline font-medium">iptv-org</a>,
|
||||
a community-maintained collection of publicly available IPTV streams. Select the channels
|
||||
you want and we'll map them into the Jefflix Live TV lineup.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground border-t border-border pt-3">
|
||||
Not all channels have working streams—we'll do our best and let you know the result.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
project_name: "Jefflix"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
milestones: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: true
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
bypass_git_hooks: false
|
||||
check_active_branches: true
|
||||
active_branch_days: 30
|
||||
task_prefix: "task"
|
||||
onStatusChange: 'python3 /home/jeffe/Github/dev-ops/scripts/backlog-notify.py'
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
id: task-1
|
||||
title: Configure Spotify API for SoulSync
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-01-08 09:05'
|
||||
updated_date: '2026-01-08 09:06'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Set up Spotify Developer app and connect to SoulSync for playlist syncing to Navidrome
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Create Spotify Developer app at developer.spotify.com/dashboard
|
||||
- [x] #2 Set redirect URI to https://soulsync.jefflix.lol/callback
|
||||
- [x] #3 Copy Client ID and Client Secret to SoulSync settings
|
||||
- [x] #4 Test Spotify connection shows green in SoulSync dashboard
|
||||
- [ ] #5 Test syncing a playlist from Spotify to Soulseek/Navidrome
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## SoulSync Deployment Info
|
||||
|
||||
**URLs:**
|
||||
- SoulSync: https://soulsync.jefflix.lol
|
||||
- slskd: https://slskd.jefflix.lol (login: slskd/slskd)
|
||||
- Navidrome: https://music.jefflix.lol
|
||||
|
||||
**Current Status (Updated 2026-01-28):**
|
||||
- Soulseek: CONNECTED (green) as jeffemmett
|
||||
- Navidrome: CONNECTED (green)
|
||||
- Spotify: ✅ CONNECTED
|
||||
|
||||
**Completed:**
|
||||
- Added Traefik route for OAuth callback (port 8888)
|
||||
- Configured Spotify credentials in SoulSync config
|
||||
- Successfully completed OAuth authorization
|
||||
- Also configured Lidarr with 35 Spotify playlists
|
||||
- qBittorrent connected to Lidarr for torrent downloads
|
||||
|
||||
**To Configure Spotify:**
|
||||
1. Go to https://developer.spotify.com/dashboard
|
||||
2. Create App named "SoulSync"
|
||||
3. Add Redirect URI: `https://soulsync.jefflix.lol/callback`
|
||||
4. Copy Client ID and Client Secret
|
||||
5. Enter in SoulSync Settings > Spotify section
|
||||
6. Save and authorize with your Spotify account
|
||||
|
||||
**Server Config:**
|
||||
- Docker compose: /opt/soulsync/docker-compose.yml
|
||||
- Config file: /opt/soulsync/config/soulsync/config/config.json
|
||||
- slskd config: /opt/soulsync/config/slskd/slskd.yml
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
id: TASK-2
|
||||
title: Integrate IPTV for live TV (Threadfin + iptv-org + Eleza TV)
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-02-04 20:56'
|
||||
updated_date: '2026-04-06 03:18'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Integrate Eleza TV (https://elezatv.com) as an IPTV source for live sports in Jellyfin. Eleza offers 40,000+ channels with EPG, SD/HD/FHD/4K quality, and 54,000+ VOD titles. Pricing: $69/yr for 1 connection, $96/yr for 2 connections. Need to: subscribe to a plan, get M3U/Xtream Codes credentials, configure Jellyfin Live TV & DVR with the IPTV source, set up EPG guide data, and replace the current Sportsnet-dependent sports button on jefflix.lol.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Subscribe to Eleza TV plan (1 or 2 connections)
|
||||
- [ ] #2 Obtain M3U playlist URL or Xtream Codes API credentials
|
||||
- [ ] #3 Configure Jellyfin Live TV with Eleza TV IPTV source
|
||||
- [ ] #4 Set up EPG/TV guide data in Jellyfin
|
||||
- [ ] #5 Test live sports playback (e.g. NBA, NFL, Premier League)
|
||||
- [ ] #6 Update jefflix.lol Live Sports button to use new channels
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
### 2026-03-22: IPTV Infrastructure Deployed
|
||||
- **Threadfin** container running on Netcup (`/opt/media-server/`)
|
||||
- Admin UI at `threadfin.jeffemmett.com` via Traefik
|
||||
- `tv.jefflix.lol` redirect → Jellyfin Live TV section configured
|
||||
- "Watch Live TV" + "Listen to Music" buttons added to jefflix.lol website
|
||||
- Using **iptv-org/iptv** (90k+ stars) for community-curated free channel lists
|
||||
|
||||
**Still TODO:**
|
||||
- Configure Threadfin with M3U playlists (English, sports, news channels)
|
||||
- Add XMLTV EPG source for program guide
|
||||
- Configure Jellyfin Live TV tuner (M3U → `http://threadfin:34400/auto/v1/m3u`)
|
||||
- Add Cloudflare DNS CNAME for `tv.jefflix.lol`
|
||||
- Enable Threadfin web auth
|
||||
- Test playback end-to-end
|
||||
|
||||
### 2026-03-22: IPTV Fully Deployed
|
||||
**All infrastructure live:**
|
||||
- Threadfin running at `threadfin.jeffemmett.com` (3,468 streams from iptv-org)
|
||||
- `tv.jefflix.lol` redirects to Jellyfin Live TV section
|
||||
- Jellyfin has 3 M3U tuners: English (2,269ch), News, Sports
|
||||
- 3,468 total Live TV channels available in Jellyfin
|
||||
- "Watch Live TV" + "Listen to Music" buttons on jefflix.lol website
|
||||
- Cloudflare DNS + tunnel configured for both subdomains
|
||||
|
||||
**Remaining manual tasks:**
|
||||
- Set up Threadfin web auth (visit threadfin.jeffemmett.com → Settings)
|
||||
- Map XEPG channels in Threadfin for curated filtered playlist
|
||||
- EPG guide requires self-hosted iptv-org/epg generator (no pre-built guides available)
|
||||
- Eleza TV subscription still optional for premium sports content
|
||||
|
||||
### 2026-04-06: EPG Guide Fixed - Full 24h Coverage
|
||||
|
||||
**Problems identified and fixed:**
|
||||
1. **Mismatched FAST streams**: 11 channels had FAST/Pluto TV streams but EPG was for real cable channels. Replaced with real tvpass.org HLS feeds.
|
||||
2. **EPG source overnight gap**: tvpassport.com only covered 10:00+ UTC. Switched 31 channels to tvguide.com/tvtv.us for full 24h coverage.
|
||||
3. **Stale Jellyfin listing provider**: Old XMLTV provider had cached state causing 6h time shifts. Fixed by deleting and re-creating the provider.
|
||||
|
||||
**Changes on Netcup (media-server stack):**
|
||||
- channels.m3u: Replaced 11 stream URLs with tvpass.org feeds, fixed display names
|
||||
- channels.xml: Switched 14 US channels to tvguide.com, 17 to tvtv.us, kept tvpassport.com for intl news
|
||||
- Jellyfin: Deleted old XMLTV listing provider, re-created fresh
|
||||
|
||||
**Result:** 40+ channels with correct EPG at any hour, up from 5-6. Fixed remaining 5 broken channels (A&E, E!, FX, Disney Channel, CBS News) by switching from stale tvguide.com site IDs to tvtv.us. All 56 EPG-configured channels now have full 24h guide data.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: TASK-3
|
||||
title: Set up Navidrome mobile access (Android + iOS)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-24 07:13'
|
||||
labels:
|
||||
- soulsync
|
||||
- mobile
|
||||
- navidrome
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://soulsync.jefflix.lol'
|
||||
- 'https://music.jefflix.lol'
|
||||
- 'https://soulseek.jefflix.lol'
|
||||
- /home/jeffe/Github/jefflix-website/soulsync-docker-compose.yml
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Configure Navidrome (music.jefflix.lol) for mobile streaming on both Android and Apple devices using Subsonic-compatible apps.
|
||||
|
||||
Navidrome exposes the Subsonic API, so no custom app development is needed - just configure a native mobile client on each platform.
|
||||
|
||||
**Recommended Apps:**
|
||||
- **Android:** Symfonium (paid, best UX), Subtracks (free/open-source), DSub, Ultrasonic
|
||||
- **iOS/Apple:** play:Sub, Amperfy (free/open-source), SubStreamer, iSub
|
||||
|
||||
**Server URL:** https://music.jefflix.lol
|
||||
|
||||
**Prerequisites:**
|
||||
- Navidrome must be accessible externally (verify Cloudflare tunnel routing)
|
||||
- User account(s) created in Navidrome
|
||||
- Subsonic API enabled in Navidrome settings (usually on by default)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Navidrome is accessible externally at music.jefflix.lol
|
||||
- [ ] #2 Subsonic API endpoint responds (music.jefflix.lol/rest/ping)
|
||||
- [ ] #3 Android app installed and streaming music successfully
|
||||
- [ ] #4 iOS app installed and streaming music successfully
|
||||
- [ ] #5 Offline download/caching tested on at least one platform
|
||||
- [ ] #6 Document the setup (app name, settings) for future reference
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
id: TASK-4
|
||||
title: Verify SoulSync playlist sync pipeline end-to-end
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-24 07:13'
|
||||
labels:
|
||||
- soulsync
|
||||
- maintenance
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Ensure the full SoulSync pipeline is working: Spotify playlist → SoulSync orchestration → Soulseek download → Navidrome library update.
|
||||
|
||||
Spotify API was configured (task-1), but we should verify the full flow works reliably before relying on mobile access.
|
||||
|
||||
**Services to check:**
|
||||
- SoulSync web UI: https://soulsync.jefflix.lol
|
||||
- slskd (Soulseek): https://soulseek.jefflix.lol
|
||||
- Navidrome: https://music.jefflix.lol
|
||||
|
||||
**Key checks:**
|
||||
- Are Spotify playlists syncing to SoulSync?
|
||||
- Are downloads completing via Soulseek?
|
||||
- Is Navidrome picking up new files from the music directory?
|
||||
- Are there any stale/failed downloads to clean up?
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 SoulSync shows Spotify playlists synced
|
||||
- [ ] #2 At least one playlist successfully downloads tracks via Soulseek
|
||||
- [ ] #3 Downloaded tracks appear in Navidrome library
|
||||
- [ ] #4 No stuck/failed jobs in the queue
|
||||
- [ ] #5 slskd Soulseek connection is healthy and sharing
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
id: TASK-5
|
||||
title: Audit games platform deployment on Netcup
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-24 07:13'
|
||||
labels:
|
||||
- games
|
||||
- infrastructure
|
||||
- audit
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://games.jeffemmett.com'
|
||||
- /home/jeffe/Github/games-platform/docker-compose.yml
|
||||
- /home/jeffe/Github/games-platform/DEPLOYMENT.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Check the current state of the games platform deployment at games.jeffemmett.com on Netcup RS 8000.
|
||||
|
||||
**What to check:**
|
||||
- Are all Docker containers running? (postgres, redis, backend, worker, frontend, nginx)
|
||||
- Is the site accessible at https://games.jeffemmett.com?
|
||||
- What games (if any) are currently in the library?
|
||||
- Check /data/games/ directories for existing ROMs
|
||||
- Review database for any game entries
|
||||
- Check if the auto-deploy webhook is working
|
||||
- Verify EmulatorJS loads correctly in browser
|
||||
|
||||
**Location on server:** /opt/apps/games-platform
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 All 6 Docker containers verified running (or restarted)
|
||||
- [ ] #2 games.jeffemmett.com loads in browser
|
||||
- [ ] #3 Inventory of existing games documented
|
||||
- [ ] #4 Database health confirmed
|
||||
- [ ] #5 Gitea webhook auto-deploy verified
|
||||
- [ ] #6 EmulatorJS emulator loads on a game page
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-6
|
||||
title: Catalog desired retro games for games platform
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-24 07:14'
|
||||
labels:
|
||||
- games
|
||||
- content
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Create a wishlist of old/retro games to add to the games platform. Go through memories of classic games across all supported platforms and build a catalog of what to source.
|
||||
|
||||
**Supported platforms:**
|
||||
- PlayStation 1 (.iso, .bin, .cue, .pbp)
|
||||
- Nintendo 64 (.z64, .n64, .v64)
|
||||
- Super Nintendo (.smc, .sfc)
|
||||
- Game Boy Advance (.gba)
|
||||
- Game Boy Color (.gbc, .gb)
|
||||
- NES (.nes)
|
||||
- Sega Genesis (.md, .bin, .gen)
|
||||
- Sega Dreamcast (.cdi, .gdi, .chd)
|
||||
- PSP (.iso, .cso, .pbp)
|
||||
|
||||
**Process:**
|
||||
1. List out all desired games by platform
|
||||
2. Note which ones are personal favorites / must-haves
|
||||
3. Research ROM availability and file sizes
|
||||
4. Prioritize which to add first
|
||||
5. Source ROM files (user handles this manually)
|
||||
6. Upload via the games platform API or direct file copy
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Game wishlist created with at least 10 games across multiple platforms
|
||||
- [ ] #2 Games prioritized by platform and personal preference
|
||||
- [ ] #3 File size estimates noted for storage planning
|
||||
- [ ] #4 Top 5 must-have games identified for first batch
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
id: TASK-7
|
||||
title: Add ROM files and populate games library
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-24 07:14'
|
||||
labels:
|
||||
- games
|
||||
- content
|
||||
dependencies:
|
||||
- TASK-5
|
||||
- TASK-4
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Once games are sourced (from task: Catalog desired retro games), upload ROMs to the games platform and register them in the database.
|
||||
|
||||
**Methods to add games:**
|
||||
1. **Direct file copy:** SCP ROMs to /data/games/{platform}/ on Netcup, then register via API
|
||||
2. **Upload API:** POST to /api/upload with ROM file
|
||||
3. **Web UI:** Upload through the games platform interface
|
||||
|
||||
**For each game added:**
|
||||
- Copy ROM to correct platform directory
|
||||
- Add cover art if available
|
||||
- Register in database with metadata (title, year, description, genre)
|
||||
- Test that it loads in EmulatorJS
|
||||
- Verify save states work
|
||||
|
||||
**Storage location on Netcup:** /data/games/{ps1,n64,snes,gba,gbc,nes,genesis,dreamcast,psp}/
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 At least 5 games uploaded and playable
|
||||
- [ ] #2 Each game has cover art and metadata
|
||||
- [ ] #3 EmulatorJS loads and runs each game
|
||||
- [ ] #4 Save states work for at least one game per platform
|
||||
- [ ] #5 Games appear correctly in the library browse page
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export type { GlobePoint } from './globe-visualization'
|
||||
|
||||
const GlobeVisualization = dynamic(
|
||||
() => import('./globe-visualization').then((m) => m.GlobeVisualization),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full w-full min-h-[300px]">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="text-sm">Loading globe...</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export { GlobeVisualization as GlobeLoader }
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import Globe from 'react-globe.gl'
|
||||
|
||||
export interface GlobePoint {
|
||||
lat: number
|
||||
lng: number
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface GlobeVisualizationProps {
|
||||
points: GlobePoint[]
|
||||
onPointClick: (point: GlobePoint) => void
|
||||
height?: number
|
||||
autoRotate?: boolean
|
||||
focusLat?: number
|
||||
focusLng?: number
|
||||
}
|
||||
|
||||
export function GlobeVisualization({
|
||||
points,
|
||||
onPointClick,
|
||||
height = 500,
|
||||
autoRotate = true,
|
||||
focusLat,
|
||||
focusLng,
|
||||
}: GlobeVisualizationProps) {
|
||||
const globeRef = useRef<any>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
// Responsive width
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width)
|
||||
}
|
||||
})
|
||||
observer.observe(containerRef.current)
|
||||
setContainerWidth(containerRef.current.clientWidth)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Configure controls after globe is ready
|
||||
const handleGlobeReady = useCallback(() => {
|
||||
const globe = globeRef.current
|
||||
if (!globe) return
|
||||
const controls = globe.controls()
|
||||
if (controls) {
|
||||
controls.autoRotate = autoRotate
|
||||
controls.autoRotateSpeed = 0.5
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.1
|
||||
}
|
||||
// Set initial view
|
||||
globe.pointOfView({ lat: focusLat ?? 20, lng: focusLng ?? 0, altitude: 2.5 }, 0)
|
||||
}, [autoRotate, focusLat, focusLng])
|
||||
|
||||
// Focus on specific coordinates when they change
|
||||
useEffect(() => {
|
||||
if (globeRef.current && focusLat != null && focusLng != null) {
|
||||
globeRef.current.pointOfView({ lat: focusLat, lng: focusLng, altitude: 1.5 }, 1000)
|
||||
}
|
||||
}, [focusLat, focusLng])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full" style={{ height }}>
|
||||
{containerWidth > 0 && (
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={containerWidth}
|
||||
height={height}
|
||||
globeImageUrl="/textures/earth-blue-marble.jpg"
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
showAtmosphere={true}
|
||||
atmosphereColor="rgba(100,150,255,0.3)"
|
||||
atmosphereAltitude={0.15}
|
||||
pointsData={points}
|
||||
pointLat="lat"
|
||||
pointLng="lng"
|
||||
pointColor="color"
|
||||
pointAltitude={0.01}
|
||||
pointRadius={(d: any) => Math.max(0.15, Math.min(0.6, (d.size || 1) * 0.12))}
|
||||
pointLabel={(d: any) => `<div class="text-sm font-medium px-2 py-1 bg-background/90 border border-border rounded shadow-lg">${d.label}</div>`}
|
||||
onPointClick={(point: any) => onPointClick(point as GlobePoint)}
|
||||
onGlobeReady={handleGlobeReady}
|
||||
enablePointerInteraction={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'use client'
|
||||
|
||||
import type { Track } from './music-provider'
|
||||
import { useOffline } from '@/lib/stores/offline'
|
||||
import { Download, Loader2, CheckCircle, Clock } from 'lucide-react'
|
||||
|
||||
interface DownloadButtonProps {
|
||||
track: Track
|
||||
className?: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function DownloadButton({ track, className = '', size = 'sm' }: DownloadButtonProps) {
|
||||
const { offlineIds, download, remove, getStatus } = useOffline()
|
||||
const isOffline = offlineIds.has(track.id)
|
||||
const status = getStatus(track.id)
|
||||
|
||||
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
|
||||
const padding = size === 'sm' ? 'p-1.5' : 'p-2'
|
||||
|
||||
if (isOffline) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); remove(track.id) }}
|
||||
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-green-500 ${className}`}
|
||||
title="Downloaded — tap to remove"
|
||||
>
|
||||
<CheckCircle className={iconSize} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'downloading') {
|
||||
return (
|
||||
<span className={`${padding} text-muted-foreground ${className}`} title="Downloading...">
|
||||
<Loader2 className={`${iconSize} animate-spin`} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'queued') {
|
||||
return (
|
||||
<span className={`${padding} text-muted-foreground ${className}`} title="Queued for download">
|
||||
<Clock className={iconSize} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); download(track) }}
|
||||
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground ${className}`}
|
||||
title="Download for offline"
|
||||
>
|
||||
<Download className={iconSize} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Drawer } from 'vaul'
|
||||
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||
import { DownloadButton } from './download-button'
|
||||
import { PlaylistPicker } from './playlist-picker'
|
||||
import { SyncedLyrics } from './synced-lyrics'
|
||||
import { QueueView } from './queue-view'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ListPlus,
|
||||
ListMusic,
|
||||
Share2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
ChevronDown,
|
||||
Speaker,
|
||||
Shuffle,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
|
||||
function formatTime(secs: number) {
|
||||
if (!secs || !isFinite(secs)) return '0:00'
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = Math.floor(secs % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function FullScreenPlayer() {
|
||||
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, toggleShuffle, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
|
||||
const router = useRouter()
|
||||
const [lyrics, setLyrics] = useState<string | null>(null)
|
||||
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
|
||||
const [loadingLyrics, setLoadingLyrics] = useState(false)
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
const [queueOpen, setQueueOpen] = useState(false)
|
||||
|
||||
const track = state.currentTrack
|
||||
|
||||
const isRadio = track ? isRadioTrack(track) : false
|
||||
|
||||
// Fetch lyrics when track changes (skip for radio)
|
||||
useEffect(() => {
|
||||
if (!track || isRadioTrack(track)) {
|
||||
setLyrics(null)
|
||||
setSyncedLyrics(null)
|
||||
setLoadingLyrics(false)
|
||||
return
|
||||
}
|
||||
setLyrics(null)
|
||||
setSyncedLyrics(null)
|
||||
setLoadingLyrics(true)
|
||||
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setLyrics(d.lyrics)
|
||||
setSyncedLyrics(d.synced || null)
|
||||
})
|
||||
.catch(() => { setLyrics(null); setSyncedLyrics(null) })
|
||||
.finally(() => setLoadingLyrics(false))
|
||||
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!track) return
|
||||
const text = `${track.title} - ${track.artist}`
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: text, text })
|
||||
} catch {}
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer.Root open={state.isFullScreen} onOpenChange={setFullScreen}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/60 z-50" />
|
||||
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
|
||||
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
|
||||
|
||||
<Drawer.Title className="sr-only">{isRadio ? 'Radio Player' : 'Music Player'}</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">
|
||||
{isRadio ? 'Live radio player with station controls' : 'Full-screen music player with controls, lyrics, and playlist management'}
|
||||
</Drawer.Description>
|
||||
|
||||
{track && (
|
||||
<div className="flex flex-col items-center px-6 pb-8 pt-4 overflow-y-auto">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setFullScreen(false)}
|
||||
className="self-start mb-4 p-1 rounded-full hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-6 w-6 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Album art */}
|
||||
<div className="w-64 h-64 sm:w-72 sm:h-72 rounded-xl overflow-hidden shadow-2xl mb-8 bg-muted flex-shrink-0">
|
||||
{track.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${track.coverArt}?size=600`}
|
||||
alt={track.album}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
{isRadio ? <Radio className="h-16 w-16 text-rose-400" /> : <Volume2 className="h-16 w-16" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title / Artist */}
|
||||
<div className="text-center mb-6 max-w-sm">
|
||||
<h2 className="text-xl font-bold truncate">{track.title}</h2>
|
||||
<button
|
||||
className="text-muted-foreground truncate hover:text-purple-400 hover:underline transition-colors"
|
||||
onClick={() => {
|
||||
setFullScreen(false)
|
||||
router.push(`/music?q=${encodeURIComponent(track.artist)}`)
|
||||
}}
|
||||
>
|
||||
{track.artist}
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground/70 truncate">{track.album}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full max-w-sm mb-4">
|
||||
{isRadio ? (
|
||||
<>
|
||||
<div className="h-2 w-full bg-rose-500/20 rounded-full overflow-hidden mb-2">
|
||||
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-center text-xs">
|
||||
<span className="font-bold px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse">LIVE</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Slider
|
||||
value={[state.progress]}
|
||||
max={state.duration || 1}
|
||||
step={1}
|
||||
onValueChange={([v]) => seek(v)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatTime(state.progress)}</span>
|
||||
<span>{formatTime(state.duration)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-6 mb-6">
|
||||
{!isRadio && (
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
|
||||
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
||||
>
|
||||
<Shuffle className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
|
||||
<SkipBack className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-4 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{state.isPlaying ? <Pause className="h-7 w-7" /> : <Play className="h-7 w-7 ml-0.5" />}
|
||||
</button>
|
||||
<button onClick={nextTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
|
||||
<SkipForward className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-3 w-full max-w-[200px] mb-6">
|
||||
<button onClick={() => setVolume(state.volume === 0 ? 0.8 : 0)} className="text-muted-foreground">
|
||||
{state.volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<Slider
|
||||
value={[state.volume * 100]}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([v]) => setVolume(v / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audio output selector */}
|
||||
{outputDevices.length > 1 && (
|
||||
<div className="flex items-center gap-2 w-full max-w-sm mb-6">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<select
|
||||
value={currentOutputId || 'default'}
|
||||
onChange={(e) => setOutputDevice(e.target.value)}
|
||||
className="flex-1 text-sm bg-muted/50 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{outputDevices.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mb-8 flex-wrap justify-center">
|
||||
<Button variant="outline" size="sm" onClick={() => setQueueOpen(true)}>
|
||||
<ListMusic className="h-4 w-4 mr-1.5" />
|
||||
Queue
|
||||
</Button>
|
||||
{!isRadio && <DownloadButton track={track} size="md" />}
|
||||
{!isRadio && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
|
||||
<ListPlus className="h-4 w-4 mr-1.5" />
|
||||
Add to Playlist
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4 mr-1.5" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lyrics (hidden for radio) */}
|
||||
{!isRadio && (
|
||||
loadingLyrics ? (
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
||||
) : syncedLyrics ? (
|
||||
<SyncedLyrics
|
||||
syncedLyrics={syncedLyrics}
|
||||
currentTime={state.progress}
|
||||
onSeek={seek}
|
||||
/>
|
||||
) : lyrics ? (
|
||||
<div className="w-full max-w-sm">
|
||||
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
|
||||
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
|
||||
{lyrics}
|
||||
</pre>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
|
||||
<PlaylistPicker open={playlistOpen} onOpenChange={setPlaylistOpen} />
|
||||
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMusicPlayer, isRadioTrack } from './music-provider'
|
||||
import { FullScreenPlayer } from './full-screen-player'
|
||||
import { QueueView } from './queue-view'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle, Radio } from 'lucide-react'
|
||||
|
||||
function formatTime(secs: number) {
|
||||
if (!secs || !isFinite(secs)) return '0:00'
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = Math.floor(secs % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen, toggleShuffle } = useMusicPlayer()
|
||||
const [queueOpen, setQueueOpen] = useState(false)
|
||||
|
||||
if (!state.currentTrack) return null
|
||||
|
||||
const track = state.currentTrack
|
||||
const isRadio = isRadioTrack(track)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
|
||||
{/* Progress bar (thin, above the player) — disabled for radio */}
|
||||
<div className="px-2">
|
||||
{isRadio ? (
|
||||
<div className="h-1 w-full bg-rose-500/40 rounded-full overflow-hidden">
|
||||
<div className="h-full w-full bg-rose-500 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<Slider
|
||||
value={[state.progress]}
|
||||
max={state.duration || 1}
|
||||
step={1}
|
||||
onValueChange={([v]) => seek(v)}
|
||||
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-2 h-14">
|
||||
{/* Cover art - clickable to open fullscreen */}
|
||||
<button
|
||||
onClick={() => setFullScreen(true)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-md overflow-hidden bg-muted"
|
||||
>
|
||||
{track.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${track.coverArt}?size=80`}
|
||||
alt={track.album}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
{isRadio && <Radio className="h-5 w-5 text-rose-400" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Track info - clickable to open fullscreen */}
|
||||
<button
|
||||
onClick={() => setFullScreen(true)}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{track.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
|
||||
</button>
|
||||
|
||||
{/* Time / LIVE badge */}
|
||||
{isRadio ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse hidden sm:block">
|
||||
LIVE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
{formatTime(state.progress)} / {formatTime(state.duration)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isRadio && (
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
|
||||
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
|
||||
>
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={prevTrack}
|
||||
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{state.isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={nextTrack}
|
||||
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQueueOpen(true)}
|
||||
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors text-muted-foreground"
|
||||
>
|
||||
<ListMusic className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FullScreenPlayer />
|
||||
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
|
||||
import { getTrackBlob } from '@/lib/offline-db'
|
||||
import { precacheUpcoming } from '@/lib/precache'
|
||||
|
||||
interface TrackBase {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
albumId: string
|
||||
duration: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
export interface MusicTrack extends TrackBase {
|
||||
type?: 'music'
|
||||
}
|
||||
|
||||
export interface RadioTrack extends TrackBase {
|
||||
type: 'radio'
|
||||
streamUrl: string
|
||||
}
|
||||
|
||||
export type Track = MusicTrack | RadioTrack
|
||||
|
||||
export function isRadioTrack(track: Track): track is RadioTrack {
|
||||
return track.type === 'radio'
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null
|
||||
queue: Track[]
|
||||
queueIndex: number
|
||||
isPlaying: boolean
|
||||
progress: number
|
||||
duration: number
|
||||
volume: number
|
||||
isFullScreen: boolean
|
||||
shuffleEnabled: boolean
|
||||
originalQueue: Track[] | null
|
||||
}
|
||||
|
||||
type PlayerAction =
|
||||
| { type: 'PLAY_TRACK'; track: Track; queue?: Track[]; index?: number }
|
||||
| { type: 'TOGGLE_PLAY' }
|
||||
| { type: 'SET_PLAYING'; playing: boolean }
|
||||
| { type: 'SET_PROGRESS'; progress: number }
|
||||
| { type: 'SET_DURATION'; duration: number }
|
||||
| { type: 'SET_VOLUME'; volume: number }
|
||||
| { type: 'NEXT_TRACK' }
|
||||
| { type: 'PREV_TRACK' }
|
||||
| { type: 'SET_FULLSCREEN'; open: boolean }
|
||||
| { type: 'ADD_TO_QUEUE'; track: Track }
|
||||
| { type: 'ADD_ALL_TO_QUEUE'; tracks: Track[] }
|
||||
| { type: 'REMOVE_FROM_QUEUE'; index: number }
|
||||
| { type: 'MOVE_IN_QUEUE'; from: number; to: number }
|
||||
| { type: 'JUMP_TO'; index: number }
|
||||
| { type: 'CLEAR_QUEUE' }
|
||||
| { type: 'TOGGLE_SHUFFLE' }
|
||||
|
||||
const initialState: PlayerState = {
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
queueIndex: -1,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
volume: 0.8,
|
||||
isFullScreen: false,
|
||||
shuffleEnabled: false,
|
||||
originalQueue: null,
|
||||
}
|
||||
|
||||
function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
|
||||
switch (action.type) {
|
||||
case 'PLAY_TRACK': {
|
||||
const queue = action.queue || [action.track]
|
||||
const index = action.index ?? 0
|
||||
return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0, shuffleEnabled: false, originalQueue: null }
|
||||
}
|
||||
case 'TOGGLE_PLAY':
|
||||
return { ...state, isPlaying: !state.isPlaying }
|
||||
case 'SET_PLAYING':
|
||||
return { ...state, isPlaying: action.playing }
|
||||
case 'SET_PROGRESS':
|
||||
return { ...state, progress: action.progress }
|
||||
case 'SET_DURATION':
|
||||
return { ...state, duration: action.duration }
|
||||
case 'SET_VOLUME':
|
||||
return { ...state, volume: action.volume }
|
||||
case 'NEXT_TRACK': {
|
||||
const next = state.queueIndex + 1
|
||||
if (next >= state.queue.length) return { ...state, isPlaying: false }
|
||||
return { ...state, currentTrack: state.queue[next], queueIndex: next, isPlaying: true, progress: 0 }
|
||||
}
|
||||
case 'PREV_TRACK': {
|
||||
// If > 3s in, restart current track
|
||||
if (state.progress > 3) return { ...state, progress: 0 }
|
||||
const prev = state.queueIndex - 1
|
||||
if (prev < 0) return { ...state, progress: 0 }
|
||||
return { ...state, currentTrack: state.queue[prev], queueIndex: prev, isPlaying: true, progress: 0 }
|
||||
}
|
||||
case 'SET_FULLSCREEN':
|
||||
return { ...state, isFullScreen: action.open }
|
||||
case 'ADD_TO_QUEUE': {
|
||||
// Insert after current track so it plays next
|
||||
const insertAt = state.queueIndex + 1
|
||||
const q2 = [...state.queue]
|
||||
q2.splice(insertAt, 0, action.track)
|
||||
// If shuffle is on, also add to originalQueue at end
|
||||
const origQ = state.originalQueue ? [...state.originalQueue, action.track] : null
|
||||
return { ...state, queue: q2, originalQueue: origQ }
|
||||
}
|
||||
case 'ADD_ALL_TO_QUEUE': {
|
||||
const insertAt = state.queueIndex + 1
|
||||
const q3 = [...state.queue]
|
||||
q3.splice(insertAt, 0, ...action.tracks)
|
||||
const origQ2 = state.originalQueue ? [...state.originalQueue, ...action.tracks] : null
|
||||
return { ...state, queue: q3, originalQueue: origQ2 }
|
||||
}
|
||||
case 'REMOVE_FROM_QUEUE': {
|
||||
const newQueue = [...state.queue]
|
||||
newQueue.splice(action.index, 1)
|
||||
let newIndex = state.queueIndex
|
||||
if (action.index < state.queueIndex) {
|
||||
newIndex--
|
||||
} else if (action.index === state.queueIndex) {
|
||||
// Removing current track: play next (or clamp)
|
||||
const clamped = Math.min(newIndex, newQueue.length - 1)
|
||||
if (clamped < 0) return { ...state, queue: [], queueIndex: -1, currentTrack: null, isPlaying: false }
|
||||
return { ...state, queue: newQueue, queueIndex: clamped, currentTrack: newQueue[clamped], progress: 0 }
|
||||
}
|
||||
return { ...state, queue: newQueue, queueIndex: newIndex }
|
||||
}
|
||||
case 'MOVE_IN_QUEUE': {
|
||||
const q = [...state.queue]
|
||||
const [moved] = q.splice(action.from, 1)
|
||||
q.splice(action.to, 0, moved)
|
||||
// Track the currently-playing song's new position
|
||||
let idx = state.queueIndex
|
||||
if (action.from === idx) {
|
||||
idx = action.to
|
||||
} else {
|
||||
if (action.from < idx) idx--
|
||||
if (action.to <= idx) idx++
|
||||
}
|
||||
return { ...state, queue: q, queueIndex: idx }
|
||||
}
|
||||
case 'JUMP_TO': {
|
||||
if (action.index < 0 || action.index >= state.queue.length) return state
|
||||
return { ...state, currentTrack: state.queue[action.index], queueIndex: action.index, isPlaying: true, progress: 0 }
|
||||
}
|
||||
case 'CLEAR_QUEUE': {
|
||||
// Keep current track, remove everything after it
|
||||
if (state.queueIndex < 0 || !state.currentTrack) return { ...state, queue: [], queueIndex: -1 }
|
||||
return { ...state, queue: [state.currentTrack], queueIndex: 0 }
|
||||
}
|
||||
case 'TOGGLE_SHUFFLE': {
|
||||
if (state.shuffleEnabled) {
|
||||
// OFF: restore original order, find current track in it
|
||||
if (!state.originalQueue) return { ...state, shuffleEnabled: false }
|
||||
const currentId = state.currentTrack?.id
|
||||
const idx = state.originalQueue.findIndex((t) => t.id === currentId)
|
||||
return { ...state, shuffleEnabled: false, queue: state.originalQueue, queueIndex: idx >= 0 ? idx : 0, originalQueue: null }
|
||||
} else {
|
||||
// ON: save original queue, Fisher-Yates shuffle items after current index
|
||||
const shuffled = [...state.queue]
|
||||
for (let i = shuffled.length - 1; i > state.queueIndex + 1; i--) {
|
||||
const j = state.queueIndex + 1 + Math.floor(Math.random() * (i - state.queueIndex))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return { ...state, shuffleEnabled: true, originalQueue: [...state.queue], queue: shuffled }
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface AudioOutputDevice {
|
||||
deviceId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface MusicContextValue {
|
||||
state: PlayerState
|
||||
playTrack: (track: Track, queue?: Track[], index?: number) => void
|
||||
togglePlay: () => void
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
nextTrack: () => void
|
||||
prevTrack: () => void
|
||||
setFullScreen: (open: boolean) => void
|
||||
addToQueue: (track: Track) => void
|
||||
addAllToQueue: (tracks: Track[]) => void
|
||||
removeFromQueue: (index: number) => void
|
||||
moveInQueue: (from: number, to: number) => void
|
||||
jumpTo: (index: number) => void
|
||||
clearQueue: () => void
|
||||
toggleShuffle: () => void
|
||||
outputDevices: AudioOutputDevice[]
|
||||
currentOutputId: string
|
||||
setOutputDevice: (deviceId: string) => void
|
||||
}
|
||||
|
||||
const MusicContext = createContext<MusicContextValue | null>(null)
|
||||
|
||||
export function useMusicPlayer() {
|
||||
const ctx = useContext(MusicContext)
|
||||
if (!ctx) throw new Error('useMusicPlayer must be used within MusicProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MusicProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, dispatch] = useReducer(playerReducer, initialState)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
// Guard against pause events fired when changing audio.src during track transitions
|
||||
const transitioningRef = useRef(false)
|
||||
|
||||
// Create audio element on mount (client only)
|
||||
useEffect(() => {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
audioRef.current = audio
|
||||
|
||||
const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime })
|
||||
const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 })
|
||||
const onEnded = () => {
|
||||
transitioningRef.current = true
|
||||
dispatch({ type: 'NEXT_TRACK' })
|
||||
}
|
||||
const onPause = () => {
|
||||
// Ignore pause events during track transitions (browser fires pause when src changes)
|
||||
if (transitioningRef.current) return
|
||||
dispatch({ type: 'SET_PLAYING', playing: false })
|
||||
}
|
||||
const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true })
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('durationchange', onDurationChange)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
audio.addEventListener('pause', onPause)
|
||||
audio.addEventListener('play', onPlay)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('durationchange', onDurationChange)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
audio.removeEventListener('pause', onPause)
|
||||
audio.removeEventListener('play', onPlay)
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Retry play with exponential backoff (handles mobile autoplay restrictions)
|
||||
const playWithRetry = useCallback((audio: HTMLAudioElement, attempts = 3) => {
|
||||
audio.play().catch((err) => {
|
||||
if (attempts > 1) {
|
||||
setTimeout(() => playWithRetry(audio, attempts - 1), 500)
|
||||
} else {
|
||||
console.warn('Autoplay blocked after retries:', err.message)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Track blob URL for cleanup
|
||||
const blobUrlRef = useRef<string | null>(null)
|
||||
|
||||
// When currentTrack changes, update audio src (offline-first)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !state.currentTrack) return
|
||||
const trackId = state.currentTrack.id
|
||||
transitioningRef.current = true
|
||||
|
||||
// Revoke previous blob URL
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current)
|
||||
blobUrlRef.current = null
|
||||
}
|
||||
|
||||
// Radio streams: use streamUrl directly, skip offline/IndexedDB
|
||||
if (isRadioTrack(state.currentTrack)) {
|
||||
audio.src = state.currentTrack.streamUrl
|
||||
playWithRetry(audio)
|
||||
requestAnimationFrame(() => { transitioningRef.current = false })
|
||||
return
|
||||
}
|
||||
|
||||
// Music: try offline first, fall back to streaming
|
||||
getTrackBlob(trackId).then((blob) => {
|
||||
// Guard: track may have changed while we awaited
|
||||
if (state.currentTrack?.id !== trackId) return
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
blobUrlRef.current = url
|
||||
audio.src = url
|
||||
} else {
|
||||
audio.src = `/api/music/stream/${trackId}`
|
||||
}
|
||||
playWithRetry(audio)
|
||||
requestAnimationFrame(() => { transitioningRef.current = false })
|
||||
}).catch(() => {
|
||||
// IndexedDB unavailable, fall back to streaming
|
||||
if (state.currentTrack?.id !== trackId) return
|
||||
audio.src = `/api/music/stream/${trackId}`
|
||||
playWithRetry(audio)
|
||||
requestAnimationFrame(() => { transitioningRef.current = false })
|
||||
})
|
||||
}, [state.currentTrack?.id, playWithRetry]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync play/pause
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !state.currentTrack) return
|
||||
if (state.isPlaying) {
|
||||
playWithRetry(audio)
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}, [state.isPlaying, state.currentTrack, playWithRetry])
|
||||
|
||||
// Sync volume
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = state.volume
|
||||
}, [state.volume])
|
||||
|
||||
// MediaSession API
|
||||
useEffect(() => {
|
||||
if (!state.currentTrack || !('mediaSession' in navigator)) return
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: state.currentTrack.title,
|
||||
artist: state.currentTrack.artist,
|
||||
album: state.currentTrack.album,
|
||||
artwork: state.currentTrack.coverArt
|
||||
? [{ src: `/api/music/cover/${state.currentTrack.coverArt}?size=300`, sizes: '300x300', type: 'image/jpeg' }]
|
||||
: [],
|
||||
})
|
||||
navigator.mediaSession.setActionHandler('play', () => dispatch({ type: 'SET_PLAYING', playing: true }))
|
||||
navigator.mediaSession.setActionHandler('pause', () => dispatch({ type: 'SET_PLAYING', playing: false }))
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => dispatch({ type: 'PREV_TRACK' }))
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
|
||||
}, [state.currentTrack])
|
||||
|
||||
// Pre-cache next 3 tracks in queue when current track changes (skip for radio)
|
||||
useEffect(() => {
|
||||
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
|
||||
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
|
||||
|
||||
const ac = new AbortController()
|
||||
const delay = setTimeout(() => {
|
||||
precacheUpcoming(state.queue, state.queueIndex, 3, ac.signal)
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(delay)
|
||||
ac.abort()
|
||||
}
|
||||
}, [state.queueIndex, state.queue])
|
||||
|
||||
// Keyboard shortcut: Space = play/pause (only when not in input)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) {
|
||||
e.preventDefault()
|
||||
dispatch({ type: 'TOGGLE_PLAY' })
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
// Audio output device selection
|
||||
const [outputDevices, setOutputDevices] = useState<AudioOutputDevice[]>([])
|
||||
const [currentOutputId, setCurrentOutputId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) return
|
||||
|
||||
const enumerate = () => {
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
const outputs = devices
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
label: d.label || (d.deviceId === 'default' ? 'Default' : `Device ${d.deviceId.slice(0, 6)}`),
|
||||
}))
|
||||
setOutputDevices(outputs)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
enumerate()
|
||||
navigator.mediaDevices.addEventListener('devicechange', enumerate)
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', enumerate)
|
||||
}, [])
|
||||
|
||||
const setOutputDevice = useCallback((deviceId: string) => {
|
||||
const audio = audioRef.current as HTMLAudioElement & { setSinkId?: (id: string) => Promise<void> }
|
||||
if (!audio?.setSinkId) return
|
||||
audio.setSinkId(deviceId).then(() => {
|
||||
setCurrentOutputId(deviceId)
|
||||
}).catch((err) => {
|
||||
console.warn('Failed to set audio output:', err.message)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => {
|
||||
dispatch({ type: 'PLAY_TRACK', track, queue, index })
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => dispatch({ type: 'TOGGLE_PLAY' }), [])
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time
|
||||
dispatch({ type: 'SET_PROGRESS', progress: time })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setVolume = useCallback((v: number) => dispatch({ type: 'SET_VOLUME', volume: v }), [])
|
||||
const nextTrack = useCallback(() => dispatch({ type: 'NEXT_TRACK' }), [])
|
||||
const prevTrack = useCallback(() => dispatch({ type: 'PREV_TRACK' }), [])
|
||||
const setFullScreen = useCallback((open: boolean) => dispatch({ type: 'SET_FULLSCREEN', open }), [])
|
||||
const addToQueue = useCallback((track: Track) => dispatch({ type: 'ADD_TO_QUEUE', track }), [])
|
||||
const addAllToQueue = useCallback((tracks: Track[]) => dispatch({ type: 'ADD_ALL_TO_QUEUE', tracks }), [])
|
||||
const removeFromQueue = useCallback((index: number) => dispatch({ type: 'REMOVE_FROM_QUEUE', index }), [])
|
||||
const moveInQueue = useCallback((from: number, to: number) => dispatch({ type: 'MOVE_IN_QUEUE', from, to }), [])
|
||||
const jumpTo = useCallback((index: number) => dispatch({ type: 'JUMP_TO', index }), [])
|
||||
const clearQueue = useCallback(() => dispatch({ type: 'CLEAR_QUEUE' }), [])
|
||||
const toggleShuffle = useCallback(() => dispatch({ type: 'TOGGLE_SHUFFLE' }), [])
|
||||
|
||||
return (
|
||||
<MusicContext.Provider value={{
|
||||
state,
|
||||
playTrack,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
setFullScreen,
|
||||
addToQueue,
|
||||
addAllToQueue,
|
||||
removeFromQueue,
|
||||
moveInQueue,
|
||||
jumpTo,
|
||||
clearQueue,
|
||||
toggleShuffle,
|
||||
outputDevices,
|
||||
currentOutputId,
|
||||
setOutputDevice,
|
||||
}}>
|
||||
{children}
|
||||
</MusicContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Download, Loader2, Check, X, Globe } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface MBResult {
|
||||
mbid: string
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
year: string
|
||||
duration: number
|
||||
score: number
|
||||
}
|
||||
|
||||
type DownloadState = 'idle' | 'downloading' | 'success' | 'error'
|
||||
|
||||
function formatDuration(secs: number) {
|
||||
if (!secs) return ''
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = Math.floor(secs % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function MusicBrainzResultsSection({ query }: { query: string }) {
|
||||
const [results, setResults] = useState<MBResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloadStates, setDownloadStates] = useState<Record<string, DownloadState>>({})
|
||||
const debounceRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length < 2) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
// 500ms debounce (800ms total after Navidrome's 300ms input debounce)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/music/musicbrainz?q=${encodeURIComponent(query)}`)
|
||||
const d = await res.json()
|
||||
setResults(d.results || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
}
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const handleDownload = async (result: MBResult) => {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'downloading' }))
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/music/slskd/auto-download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist: result.artist, title: result.title }),
|
||||
})
|
||||
const d = await res.json()
|
||||
|
||||
if (d.success) {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'success' }))
|
||||
} else {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
|
||||
setTimeout(() => {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
|
||||
}, 3000)
|
||||
}
|
||||
} catch {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
|
||||
setTimeout(() => {
|
||||
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && results.length === 0) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
|
||||
<Globe className="h-4 w-4" />
|
||||
Discover New Music
|
||||
</h3>
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
|
||||
<Globe className="h-4 w-4" />
|
||||
Discover New Music
|
||||
</h3>
|
||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
||||
{results.map((r) => {
|
||||
const state = downloadStates[r.mbid] || 'idle'
|
||||
return (
|
||||
<div key={r.mbid} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{r.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{r.artist}
|
||||
{r.album && <> · {r.album}</>}
|
||||
{r.year && <> · {r.year}</>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
{formatDuration(r.duration)}
|
||||
</span>
|
||||
{r.score > 80 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 hidden sm:inline-flex">
|
||||
{r.score}%
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(r)}
|
||||
disabled={state === 'downloading' || state === 'success'}
|
||||
className={
|
||||
state === 'success'
|
||||
? 'bg-green-600 hover:bg-green-600 text-white'
|
||||
: state === 'error'
|
||||
? 'bg-red-600 hover:bg-red-600 text-white'
|
||||
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
|
||||
}
|
||||
>
|
||||
{state === 'downloading' && (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
<span className="hidden sm:inline">Searching...</span>
|
||||
</>
|
||||
)}
|
||||
{state === 'success' && (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Queued</span>
|
||||
</>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Not found</span>
|
||||
</>
|
||||
)}
|
||||
{state === 'idle' && (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Soulseek</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ListPlus, Plus, Loader2, CheckCircle } from 'lucide-react'
|
||||
import { useMusicPlayer } from './music-provider'
|
||||
|
||||
interface Playlist {
|
||||
id: string
|
||||
name: string
|
||||
songCount: number
|
||||
coverArt: string
|
||||
}
|
||||
|
||||
export function PlaylistPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { state } = useMusicPlayer()
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState<string | null>(null)
|
||||
const [added, setAdded] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
setAdded(null)
|
||||
fetch('/api/music/playlists')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPlaylists(d.playlists || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const songId = state.currentTrack?.id
|
||||
if (!songId) return null
|
||||
|
||||
const addToPlaylist = async (playlistId: string) => {
|
||||
setAdding(playlistId)
|
||||
try {
|
||||
await fetch(`/api/music/playlist/${playlistId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId }),
|
||||
})
|
||||
setAdded(playlistId)
|
||||
} catch {}
|
||||
setAdding(null)
|
||||
}
|
||||
|
||||
const createPlaylist = async () => {
|
||||
if (!newName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await fetch('/api/music/playlist/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), songId }),
|
||||
})
|
||||
setAdded('new')
|
||||
setNewName('')
|
||||
} catch {}
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ListPlus className="h-5 w-5" />
|
||||
Add to Playlist
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add “{state.currentTrack?.title}” to an existing playlist or create a new one.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-1">
|
||||
{playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => addToPlaylist(p.id)}
|
||||
disabled={adding !== null}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
||||
{p.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${p.coverArt}?size=80`}
|
||||
alt={p.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
|
||||
</div>
|
||||
{adding === p.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
) : added === p.id ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{playlists.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No playlists yet. Create one below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-border">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New playlist name..."
|
||||
className="flex-1 px-3 py-2 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPlaylist()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createPlaylist}
|
||||
disabled={!newName.trim() || creating}
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{added === 'new' && (
|
||||
<p className="text-sm text-green-500 flex items-center gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
Playlist created with song added
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useMusicPlayer } from './music-provider'
|
||||
import { X, GripVertical, ArrowLeft, Search, Music } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface QueueViewProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function QueueView({ open, onClose }: QueueViewProps) {
|
||||
const { state, removeFromQueue, moveInQueue, jumpTo, clearQueue } = useMusicPlayer()
|
||||
const router = useRouter()
|
||||
|
||||
// Drag state
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
const dragStartY = useRef(0)
|
||||
const dragNodeRef = useRef<HTMLElement | null>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const upcomingStart = state.queueIndex + 1
|
||||
const upcoming = state.queue.slice(upcomingStart)
|
||||
|
||||
// Map upcoming array index to absolute queue index
|
||||
const toAbsolute = (i: number) => upcomingStart + i
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent | React.TouchEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||
dragStartY.current = clientY
|
||||
setDragIndex(index)
|
||||
setOverIndex(index)
|
||||
dragNodeRef.current = (e.target as HTMLElement).closest('[data-queue-row]') as HTMLElement
|
||||
}, [])
|
||||
|
||||
const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (dragIndex === null || !listRef.current) return
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||
|
||||
// Find which row we're over
|
||||
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-queue-row]')
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect()
|
||||
if (clientY >= rect.top && clientY <= rect.bottom) {
|
||||
setOverIndex(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [dragIndex])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
|
||||
moveInQueue(toAbsolute(dragIndex), toAbsolute(overIndex))
|
||||
}
|
||||
setDragIndex(null)
|
||||
setOverIndex(null)
|
||||
dragNodeRef.current = null
|
||||
}, [dragIndex, overIndex, moveInQueue, upcomingStart]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (dragIndex === null) return
|
||||
const onMove = (e: MouseEvent | TouchEvent) => handleDragMove(e)
|
||||
const onEnd = () => handleDragEnd()
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onEnd)
|
||||
window.addEventListener('touchmove', onMove, { passive: false })
|
||||
window.addEventListener('touchend', onEnd)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onEnd)
|
||||
window.removeEventListener('touchmove', onMove)
|
||||
window.removeEventListener('touchend', onEnd)
|
||||
}
|
||||
}, [dragIndex, handleDragMove, handleDragEnd])
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const currentTrack = state.currentTrack
|
||||
const hasUpcoming = upcoming.length > 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[250] bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border flex-shrink-0">
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-muted/50 rounded-full transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">Queue</h2>
|
||||
{hasUpcoming ? (
|
||||
<button
|
||||
onClick={clearQueue}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-12" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Now Playing */}
|
||||
{currentTrack && (
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Now Playing</p>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
{currentTrack.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${currentTrack.coverArt}?size=80`}
|
||||
alt={currentTrack.album}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<Music className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{currentTrack.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{currentTrack.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Up */}
|
||||
{hasUpcoming && (
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Next Up</p>
|
||||
<div ref={listRef}>
|
||||
{upcoming.map((track, i) => {
|
||||
const isDragging = dragIndex === i
|
||||
const showIndicator = overIndex !== null && dragIndex !== null && overIndex !== dragIndex && overIndex === i
|
||||
|
||||
return (
|
||||
<div key={`${track.id}-${i}`} data-queue-row>
|
||||
{/* Drop indicator line */}
|
||||
{showIndicator && dragIndex !== null && dragIndex > i && (
|
||||
<div className="h-0.5 bg-primary rounded-full mx-3 -mb-0.5" />
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-1 py-2 rounded-lg transition-colors ${
|
||||
isDragging ? 'opacity-50 bg-muted/30' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
style={{ willChange: isDragging ? 'transform' : 'auto' }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
className="p-1.5 touch-none cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
onMouseDown={(e) => handleDragStart(e, i)}
|
||||
onTouchStart={(e) => handleDragStart(e, i)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Track info — tap to jump */}
|
||||
<button
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
onClick={() => {
|
||||
jumpTo(toAbsolute(i))
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
{track.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${track.coverArt}?size=80`}
|
||||
alt={track.album}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<Music className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{track.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Remove button */}
|
||||
<button
|
||||
className="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
onClick={() => removeFromQueue(toAbsolute(i))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Drop indicator line (below) */}
|
||||
{showIndicator && dragIndex !== null && dragIndex < i && (
|
||||
<div className="h-0.5 bg-primary rounded-full mx-3 -mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!currentTrack && !hasUpcoming && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Music className="h-12 w-12 mb-4 opacity-40" />
|
||||
<p className="text-lg font-medium mb-1">Queue is empty</p>
|
||||
<p className="text-sm">Search for songs to start listening</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Songs link */}
|
||||
<div className="px-4 py-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose()
|
||||
router.push('/music')
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
Add Songs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMusicPlayer, type Track } from './music-provider'
|
||||
import { DownloadButton } from './download-button'
|
||||
import { SwipeableRow } from './swipeable-row'
|
||||
import { Play, Pause, ListPlus } from 'lucide-react'
|
||||
|
||||
function formatDuration(secs: number) {
|
||||
if (!secs) return ''
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = Math.floor(secs % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function SongRow({
|
||||
song,
|
||||
songs,
|
||||
index,
|
||||
showDownload = false,
|
||||
}: {
|
||||
song: Track
|
||||
songs: Track[]
|
||||
index: number
|
||||
showDownload?: boolean
|
||||
}) {
|
||||
const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer()
|
||||
const isActive = state.currentTrack?.id === song.id
|
||||
const isPlaying = isActive && state.isPlaying
|
||||
const [confirmSwitch, setConfirmSwitch] = useState(false)
|
||||
|
||||
const handlePlay = () => {
|
||||
if (isActive) {
|
||||
togglePlay()
|
||||
} else if (state.currentTrack && state.isPlaying) {
|
||||
setConfirmSwitch(true)
|
||||
} else {
|
||||
playTrack(song, songs, index)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SwipeableRow onSwipeRight={() => addToQueue(song)}>
|
||||
<div
|
||||
className={`relative flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
|
||||
isActive ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Confirm switch overlay */}
|
||||
{confirmSwitch && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-background/95 backdrop-blur-sm rounded px-4">
|
||||
<span className="text-sm font-medium truncate mr-auto">Switch song, DJ Cutoff?</span>
|
||||
<button
|
||||
onClick={() => { playTrack(song, songs, index); setConfirmSwitch(false) }}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmSwitch(false)}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-muted hover:bg-muted/80 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play button / track number */}
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 text-primary ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Cover art */}
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
||||
{song.coverArt ? (
|
||||
<img
|
||||
src={`/api/music/cover/${song.coverArt}?size=80`}
|
||||
alt={song.album}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isActive ? 'text-primary' : ''}`}>
|
||||
{song.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{song.artist} · {song.album}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
|
||||
{/* Download */}
|
||||
{showDownload && <DownloadButton track={song} />}
|
||||
|
||||
{/* Add to queue (desktop hover) */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); addToQueue(song) }}
|
||||
className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-muted/50 rounded transition-all"
|
||||
title="Add to queue"
|
||||
>
|
||||
<ListPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</SwipeableRow>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState, useCallback, type ReactNode } from 'react'
|
||||
import { ListPlus } from 'lucide-react'
|
||||
|
||||
const THRESHOLD = 60
|
||||
|
||||
export function SwipeableRow({
|
||||
onSwipeRight,
|
||||
children,
|
||||
}: {
|
||||
onSwipeRight: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const touchStartX = useRef(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [swiping, setSwiping] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
setSwiping(true)
|
||||
}, [])
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!swiping) return
|
||||
const delta = e.touches[0].clientX - touchStartX.current
|
||||
// Only allow right swipe, cap at 120px
|
||||
setOffset(Math.max(0, Math.min(delta, 120)))
|
||||
}, [swiping])
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (offset > THRESHOLD) {
|
||||
onSwipeRight()
|
||||
setConfirmed(true)
|
||||
setTimeout(() => setConfirmed(false), 600)
|
||||
}
|
||||
setOffset(0)
|
||||
setSwiping(false)
|
||||
}, [offset, onSwipeRight])
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden touch-pan-y">
|
||||
{/* Green reveal strip behind */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 flex items-center gap-2 pl-4 transition-colors ${
|
||||
confirmed ? 'bg-green-500' : offset > THRESHOLD ? 'bg-green-600' : 'bg-green-600/80'
|
||||
}`}
|
||||
style={{ width: Math.max(offset, confirmed ? 200 : 0) }}
|
||||
>
|
||||
<ListPlus className="h-4 w-4 text-white flex-shrink-0" />
|
||||
<span className="text-sm text-white font-medium whitespace-nowrap">
|
||||
{confirmed ? 'Added!' : 'Add to Queue'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Swipeable content */}
|
||||
<div
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className="relative bg-background"
|
||||
style={{
|
||||
transform: `translateX(${offset}px)`,
|
||||
transition: swiping ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react'
|
||||
|
||||
interface LyricLine {
|
||||
time: number // seconds
|
||||
text: string
|
||||
}
|
||||
|
||||
function parseLRC(lrc: string): LyricLine[] {
|
||||
const lines: LyricLine[] = []
|
||||
for (const raw of lrc.split('\n')) {
|
||||
const match = raw.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s?(.*)$/)
|
||||
if (!match) continue
|
||||
const mins = parseInt(match[1], 10)
|
||||
const secs = parseInt(match[2], 10)
|
||||
const ms = parseInt(match[3].padEnd(3, '0'), 10)
|
||||
const text = match[4].trim()
|
||||
if (!text) continue
|
||||
lines.push({ time: mins * 60 + secs + ms / 1000, text })
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
export function SyncedLyrics({
|
||||
syncedLyrics,
|
||||
currentTime,
|
||||
onSeek,
|
||||
}: {
|
||||
syncedLyrics: string
|
||||
currentTime: number
|
||||
onSeek?: (time: number) => void
|
||||
}) {
|
||||
const lines = useMemo(() => parseLRC(syncedLyrics), [syncedLyrics])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const activeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Find active line index
|
||||
let activeIndex = -1
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (currentTime >= lines[i].time) {
|
||||
activeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to active line
|
||||
useEffect(() => {
|
||||
if (activeRef.current && containerRef.current) {
|
||||
const container = containerRef.current
|
||||
const el = activeRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const elRect = el.getBoundingClientRect()
|
||||
|
||||
// Center the active line in the visible area
|
||||
const targetScroll = el.offsetTop - container.offsetTop - containerRect.height / 2 + elRect.height / 2
|
||||
container.scrollTo({ top: targetScroll, behavior: 'smooth' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
if (lines.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Lyrics</h3>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="max-h-[300px] overflow-y-auto scroll-smooth space-y-1 pr-2"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
{/* Top padding so first line can center */}
|
||||
<div className="h-[120px]" />
|
||||
{lines.map((line, i) => {
|
||||
const isActive = i === activeIndex
|
||||
const isPast = i < activeIndex
|
||||
return (
|
||||
<button
|
||||
key={`${i}-${line.time}`}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
onClick={() => onSeek?.(line.time)}
|
||||
className={`block w-full text-left px-2 py-1.5 rounded-md transition-all duration-300 ${
|
||||
isActive
|
||||
? 'text-foreground text-lg font-semibold scale-[1.02]'
|
||||
: isPast
|
||||
? 'text-muted-foreground/40 text-sm'
|
||||
: 'text-muted-foreground/60 text-sm'
|
||||
} hover:bg-muted/30`}
|
||||
>
|
||||
{line.text}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Bottom padding so last line can center */}
|
||||
<div className="h-[120px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((reg) => {
|
||||
// Check for updates periodically (every 60s, matching version poll)
|
||||
setInterval(() => reg.update().catch(() => {}), 60_000)
|
||||
|
||||
// Also check on visibility change
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
reg.update().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('SW registration failed:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-bar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = value ?? defaultValue ?? [min]
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="bg-primary absolute h-full" />
|
||||
</SliderPrimitive.Track>
|
||||
{_values.map((_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className="border-primary/50 bg-background block size-4 rounded-full border shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Download } from 'lucide-react'
|
||||
|
||||
const CHECK_INTERVAL = 60_000 // check every 60s
|
||||
|
||||
export function UpdateBanner() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [initialVersion, setInitialVersion] = useState<string | null>(null)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
const checkVersion = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/version', { cache: 'no-store' })
|
||||
const { version } = await res.json()
|
||||
if (!version) return
|
||||
|
||||
if (initialVersion === null) {
|
||||
setInitialVersion(version)
|
||||
} else if (version !== initialVersion) {
|
||||
setUpdateAvailable(true)
|
||||
}
|
||||
} catch {
|
||||
// network error, skip
|
||||
}
|
||||
}, [initialVersion])
|
||||
|
||||
useEffect(() => {
|
||||
checkVersion()
|
||||
const id = setInterval(checkVersion, CHECK_INTERVAL)
|
||||
return () => clearInterval(id)
|
||||
}, [checkVersion])
|
||||
|
||||
// Also check on visibility change (user returns to tab)
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') checkVersion()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [checkVersion])
|
||||
|
||||
if (!updateAvailable) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
// 1. Clear all service worker caches
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((k) => caches.delete(k)))
|
||||
|
||||
// 2. If a new service worker is waiting, activate it
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg?.waiting) {
|
||||
// Listen for the new SW to take control, then reload
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
window.location.reload()
|
||||
}, { once: true })
|
||||
reg.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
// Fallback reload if controllerchange doesn't fire within 3s
|
||||
setTimeout(() => window.location.reload(), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. If there's an active SW but no waiting one, unregister and reload
|
||||
if (reg) {
|
||||
await reg.unregister()
|
||||
}
|
||||
} catch {
|
||||
// If anything fails, just reload
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-0 z-[100] bg-gradient-to-r from-purple-700 via-purple-600 to-indigo-600 text-white shadow-2xl animate-in slide-in-from-top duration-300">
|
||||
<div className="mx-auto max-w-2xl px-4 py-4 flex flex-col items-center gap-3 sm:flex-row sm:justify-between">
|
||||
<div className="flex items-center gap-3 text-center sm:text-left">
|
||||
<div className="rounded-full bg-white/20 p-2 animate-pulse">
|
||||
<Download className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold tracking-tight sm:text-lg">
|
||||
Update Available
|
||||
</h2>
|
||||
<p className="text-xs text-purple-100 sm:text-sm">
|
||||
A new version of Jefflix has been deployed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
className="flex items-center gap-2 rounded-full bg-white text-purple-700 font-bold px-6 py-2.5 text-sm shadow-lg hover:bg-purple-50 active:scale-95 transition-all cursor-pointer disabled:opacity-70 whitespace-nowrap"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${updating ? 'animate-spin' : ''}`} />
|
||||
{updating ? 'Updating…' : 'Update Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,13 +2,46 @@ services:
|
|||
jefflix:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- /opt/infisical/entrypoint-wrapper.sh:/infisical-entrypoint.sh:ro
|
||||
entrypoint: ["/infisical-entrypoint.sh"]
|
||||
command: ["node", "server.js"]
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
||||
- INFISICAL_PROJECT_SLUG=claude-ops
|
||||
- INFISICAL_SECRET_PATH=/media
|
||||
- INFISICAL_URL=http://infisical:8080
|
||||
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
|
||||
- THREADFIN_URL=https://threadfin.jefflix.lol
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.jefflix.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
||||
- "traefik.http.services.jefflix.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
||||
- "traefik.http.services.jefflix-website.loadbalancer.server.port=3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-public
|
||||
- infisical-internal
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
infisical-internal:
|
||||
external: true
|
||||
name: infisical_infisical-internal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { createCanvas } from 'canvas';
|
||||
import fs from 'fs';
|
||||
|
||||
// Generate emoji favicon PNGs
|
||||
const sizes = [
|
||||
{ name: 'icon-light-32x32.png', size: 32 },
|
||||
{ name: 'icon-dark-32x32.png', size: 32 },
|
||||
{ name: 'apple-icon.png', size: 180 },
|
||||
];
|
||||
|
||||
for (const { name, size } of sizes) {
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Transparent background
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// Draw emoji
|
||||
const fontSize = size * 0.85;
|
||||
ctx.font = `${fontSize}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('🤣', size / 2, size / 2 + size * 0.05);
|
||||
|
||||
// Save
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync(`public/${name}`, buffer);
|
||||
console.log(`Generated ${name}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
jfa-go:
|
||||
image: hrfee/jfa-go
|
||||
container_name: jfa-go
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8056:8056"
|
||||
volumes:
|
||||
- ./jfa-go-data:/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.jfa-go.rule=Host(`invite.jefflix.lol`)"
|
||||
- "traefik.http.services.jfa-go.loadbalancer.server.port=8056"
|
||||
networks:
|
||||
- traefik-public
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
/**
|
||||
* ISO 3166-1 alpha-2 country codes mapped to [latitude, longitude] centroids.
|
||||
* Coordinates represent approximate geographic centroids of each country.
|
||||
*/
|
||||
export const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
|
||||
AD: [42.5462, 1.6016], // Andorra
|
||||
AE: [23.4241, 53.8478], // United Arab Emirates
|
||||
AF: [33.9391, 67.7100], // Afghanistan
|
||||
AG: [17.0608, -61.7964], // Antigua and Barbuda
|
||||
AI: [18.2206, -63.0686], // Anguilla
|
||||
AL: [41.1533, 20.1683], // Albania
|
||||
AM: [40.0691, 45.0382], // Armenia
|
||||
AO: [-11.2027, 17.8739], // Angola
|
||||
AQ: [-90.0000, 0.0000], // Antarctica
|
||||
AR: [-38.4161, -63.6167],// Argentina
|
||||
AS: [-14.2710, -170.1322],// American Samoa
|
||||
AT: [47.5162, 14.5501], // Austria
|
||||
AU: [-25.2744, 133.7751],// Australia
|
||||
AW: [12.5211, -69.9683], // Aruba
|
||||
AX: [60.1785, 19.9156], // Aland Islands
|
||||
AZ: [40.1431, 47.5769], // Azerbaijan
|
||||
BA: [43.9159, 17.6791], // Bosnia and Herzegovina
|
||||
BB: [13.1939, -59.5432], // Barbados
|
||||
BD: [23.6850, 90.3563], // Bangladesh
|
||||
BE: [50.5039, 4.4699], // Belgium
|
||||
BF: [12.3641, -1.5275], // Burkina Faso
|
||||
BG: [42.7339, 25.4858], // Bulgaria
|
||||
BH: [26.0275, 50.5500], // Bahrain
|
||||
BI: [-3.3731, 29.9189], // Burundi
|
||||
BJ: [9.3077, 2.3158], // Benin
|
||||
BL: [17.9000, -62.8333], // Saint Barthelemy
|
||||
BM: [32.3213, -64.7572], // Bermuda
|
||||
BN: [4.5353, 114.7277], // Brunei
|
||||
BO: [-16.2902, -63.5887],// Bolivia
|
||||
BQ: [12.1784, -68.2385], // Bonaire, Saint Eustatius and Saba
|
||||
BR: [-14.2350, -51.9253],// Brazil
|
||||
BS: [25.0343, -77.3963], // Bahamas
|
||||
BT: [27.5142, 90.4336], // Bhutan
|
||||
BV: [-54.4208, 3.3464], // Bouvet Island
|
||||
BW: [-22.3285, 24.6849], // Botswana
|
||||
BY: [53.7098, 27.9534], // Belarus
|
||||
BZ: [17.1899, -88.4976], // Belize
|
||||
CA: [56.1304, -106.3468],// Canada
|
||||
CC: [-12.1642, 96.8710], // Cocos (Keeling) Islands
|
||||
CD: [-4.0383, 21.7587], // Democratic Republic of the Congo
|
||||
CF: [6.6111, 20.9394], // Central African Republic
|
||||
CG: [-0.2280, 15.8277], // Republic of the Congo
|
||||
CH: [46.8182, 8.2275], // Switzerland
|
||||
CI: [7.5400, -5.5471], // Ivory Coast
|
||||
CK: [-21.2368, -159.7777],// Cook Islands
|
||||
CL: [-35.6751, -71.5430],// Chile
|
||||
CM: [3.8480, 11.5021], // Cameroon
|
||||
CN: [35.8617, 104.1954], // China
|
||||
CO: [4.5709, -74.2973], // Colombia
|
||||
CR: [9.7489, -83.7534], // Costa Rica
|
||||
CU: [21.5218, -77.7812], // Cuba
|
||||
CV: [16.5388, -23.0418], // Cape Verde
|
||||
CW: [12.1696, -68.9900], // Curacao
|
||||
CX: [-10.4475, 105.6904],// Christmas Island
|
||||
CY: [35.1264, 33.4299], // Cyprus
|
||||
CZ: [49.8175, 15.4730], // Czech Republic
|
||||
DE: [51.1657, 10.4515], // Germany
|
||||
DJ: [11.8251, 42.5903], // Djibouti
|
||||
DK: [56.2639, 9.5018], // Denmark
|
||||
DM: [15.4150, -61.3710], // Dominica
|
||||
DO: [18.7357, -70.1627], // Dominican Republic
|
||||
DZ: [28.0339, 1.6596], // Algeria
|
||||
EC: [-1.8312, -78.1834], // Ecuador
|
||||
EE: [58.5953, 25.0136], // Estonia
|
||||
EG: [26.8206, 30.8025], // Egypt
|
||||
EH: [24.2155, -12.8858], // Western Sahara
|
||||
ER: [15.1794, 39.7823], // Eritrea
|
||||
ES: [40.4637, -3.7492], // Spain
|
||||
ET: [9.1450, 40.4897], // Ethiopia
|
||||
FI: [61.9241, 25.7482], // Finland
|
||||
FJ: [-16.5782, 179.4144],// Fiji
|
||||
FK: [-51.7963, -59.5236],// Falkland Islands
|
||||
FM: [7.4256, 150.5508], // Micronesia
|
||||
FO: [61.8926, -6.9118], // Faroe Islands
|
||||
FR: [46.2276, 2.2137], // France
|
||||
GA: [-0.8037, 11.6094], // Gabon
|
||||
GB: [55.3781, -3.4360], // United Kingdom
|
||||
GD: [12.1165, -61.6790], // Grenada
|
||||
GE: [42.3154, 43.3569], // Georgia
|
||||
GF: [3.9339, -53.1258], // French Guiana
|
||||
GG: [49.4657, -2.5853], // Guernsey
|
||||
GH: [7.9465, -1.0232], // Ghana
|
||||
GI: [36.1408, -5.3536], // Gibraltar
|
||||
GL: [71.7069, -42.6043], // Greenland
|
||||
GM: [13.4432, -15.3101], // Gambia
|
||||
GN: [9.9456, -11.2874], // Guinea
|
||||
GP: [16.9950, -62.0670], // Guadeloupe
|
||||
GQ: [1.6508, 10.2679], // Equatorial Guinea
|
||||
GR: [39.0742, 21.8243], // Greece
|
||||
GS: [-54.4296, -36.5879],// South Georgia and the South Sandwich Islands
|
||||
GT: [15.7835, -90.2308], // Guatemala
|
||||
GU: [13.4443, 144.7937], // Guam
|
||||
GW: [11.8037, -15.1804], // Guinea-Bissau
|
||||
GY: [4.8604, -58.9302], // Guyana
|
||||
HK: [22.3193, 114.1694], // Hong Kong
|
||||
HM: [-53.0818, 73.5042], // Heard Island and McDonald Islands
|
||||
HN: [15.1999, -86.2419], // Honduras
|
||||
HR: [45.1000, 15.2000], // Croatia
|
||||
HT: [18.9712, -72.2852], // Haiti
|
||||
HU: [47.1625, 19.5033], // Hungary
|
||||
ID: [-0.7893, 113.9213], // Indonesia
|
||||
IE: [53.4129, -8.2439], // Ireland
|
||||
IL: [31.0461, 34.8516], // Israel
|
||||
IM: [54.2361, -4.5481], // Isle of Man
|
||||
IN: [20.5937, 78.9629], // India
|
||||
IO: [-6.3432, 71.8765], // British Indian Ocean Territory
|
||||
IQ: [33.2232, 43.6793], // Iraq
|
||||
IR: [32.4279, 53.6880], // Iran
|
||||
IS: [64.9631, -19.0208], // Iceland
|
||||
IT: [41.8719, 12.5674], // Italy
|
||||
JE: [49.2144, -2.1312], // Jersey
|
||||
JM: [18.1096, -77.2975], // Jamaica
|
||||
JO: [30.5852, 36.2384], // Jordan
|
||||
JP: [36.2048, 138.2529], // Japan
|
||||
KE: [-0.0236, 37.9062], // Kenya
|
||||
KG: [41.2044, 74.7661], // Kyrgyzstan
|
||||
KH: [12.5657, 104.9910], // Cambodia
|
||||
KI: [-3.3704, -168.7340],// Kiribati
|
||||
KM: [-11.8750, 43.8722], // Comoros
|
||||
KN: [17.3578, -62.7830], // Saint Kitts and Nevis
|
||||
KP: [40.3399, 127.5101], // North Korea
|
||||
KR: [35.9078, 127.7669], // South Korea
|
||||
KW: [29.3117, 47.4818], // Kuwait
|
||||
KY: [19.3133, -81.2546], // Cayman Islands
|
||||
KZ: [48.0196, 66.9237], // Kazakhstan
|
||||
LA: [19.8563, 102.4955], // Laos
|
||||
LB: [33.8547, 35.8623], // Lebanon
|
||||
LC: [13.9094, -60.9789], // Saint Lucia
|
||||
LI: [47.1660, 9.5554], // Liechtenstein
|
||||
LK: [7.8731, 80.7718], // Sri Lanka
|
||||
LR: [6.4281, -9.4295], // Liberia
|
||||
LS: [-29.6100, 28.2336], // Lesotho
|
||||
LT: [55.1694, 23.8813], // Lithuania
|
||||
LU: [49.8153, 6.1296], // Luxembourg
|
||||
LV: [56.8796, 24.6032], // Latvia
|
||||
LY: [26.3351, 17.2283], // Libya
|
||||
MA: [31.7917, -7.0926], // Morocco
|
||||
MC: [43.7384, 7.4246], // Monaco
|
||||
MD: [47.4116, 28.3699], // Moldova
|
||||
ME: [42.7087, 19.3744], // Montenegro
|
||||
MF: [18.0708, -63.0501], // Saint Martin
|
||||
MG: [-18.7669, 46.8691], // Madagascar
|
||||
MH: [7.1315, 171.1845], // Marshall Islands
|
||||
MK: [41.6086, 21.7453], // North Macedonia
|
||||
ML: [17.5707, -3.9962], // Mali
|
||||
MM: [21.9162, 95.9560], // Myanmar
|
||||
MN: [46.8625, 103.8467], // Mongolia
|
||||
MO: [22.1987, 113.5439], // Macao
|
||||
MP: [17.3308, 145.3847], // Northern Mariana Islands
|
||||
MQ: [14.6415, -61.0242], // Martinique
|
||||
MR: [21.0079, -10.9408], // Mauritania
|
||||
MS: [16.7425, -62.1874], // Montserrat
|
||||
MT: [35.9375, 14.3754], // Malta
|
||||
MU: [-20.3484, 57.5522], // Mauritius
|
||||
MV: [3.2028, 73.2207], // Maldives
|
||||
MW: [-13.2543, 34.3015], // Malawi
|
||||
MX: [23.6345, -102.5528],// Mexico
|
||||
MY: [4.2105, 101.9758], // Malaysia
|
||||
MZ: [-18.6657, 35.5296], // Mozambique
|
||||
NA: [-22.9576, 18.4904], // Namibia
|
||||
NC: [-20.9043, 165.6180],// New Caledonia
|
||||
NE: [17.6078, 8.0817], // Niger
|
||||
NF: [-29.0408, 167.9547],// Norfolk Island
|
||||
NG: [9.0820, 8.6753], // Nigeria
|
||||
NI: [12.8654, -85.2072], // Nicaragua
|
||||
NL: [52.1326, 5.2913], // Netherlands
|
||||
NO: [60.4720, 8.4689], // Norway
|
||||
NP: [28.3949, 84.1240], // Nepal
|
||||
NR: [-0.5228, 166.9315], // Nauru
|
||||
NU: [-19.0544, -169.8672],// Niue
|
||||
NZ: [-40.9006, 174.8860],// New Zealand
|
||||
OM: [21.4735, 55.9754], // Oman
|
||||
PA: [8.5380, -80.7821], // Panama
|
||||
PE: [-9.1900, -75.0152], // Peru
|
||||
PF: [-17.6797, -149.4068],// French Polynesia
|
||||
PG: [-6.3150, 143.9555], // Papua New Guinea
|
||||
PH: [12.8797, 121.7740], // Philippines
|
||||
PK: [30.3753, 69.3451], // Pakistan
|
||||
PL: [51.9194, 19.1451], // Poland
|
||||
PM: [46.8852, -56.3159], // Saint Pierre and Miquelon
|
||||
PN: [-24.7036, -127.4392],// Pitcairn
|
||||
PR: [18.2208, -66.5901], // Puerto Rico
|
||||
PS: [31.9522, 35.2332], // Palestinian Territory
|
||||
PT: [39.3999, -8.2245], // Portugal
|
||||
PW: [7.5150, 134.5825], // Palau
|
||||
PY: [-23.4425, -58.4438],// Paraguay
|
||||
QA: [25.3548, 51.1839], // Qatar
|
||||
RE: [-21.1151, 55.5364], // Reunion
|
||||
RO: [45.9432, 24.9668], // Romania
|
||||
RS: [44.0165, 21.0059], // Serbia
|
||||
RU: [61.5240, 105.3188], // Russia
|
||||
RW: [-1.9403, 29.8739], // Rwanda
|
||||
SA: [23.8859, 45.0792], // Saudi Arabia
|
||||
SB: [-9.6457, 160.1562], // Solomon Islands
|
||||
SC: [-4.6796, 55.4920], // Seychelles
|
||||
SD: [12.8628, 30.2176], // Sudan
|
||||
SE: [60.1282, 18.6435], // Sweden
|
||||
SG: [1.3521, 103.8198], // Singapore
|
||||
SH: [-24.1435, -10.0307],// Saint Helena
|
||||
SI: [46.1512, 14.9955], // Slovenia
|
||||
SJ: [77.5536, 23.6703], // Svalbard and Jan Mayen
|
||||
SK: [48.6690, 19.6990], // Slovakia
|
||||
SL: [8.4606, -11.7799], // Sierra Leone
|
||||
SM: [43.9424, 12.4578], // San Marino
|
||||
SN: [14.4974, -14.4524], // Senegal
|
||||
SO: [5.1521, 46.1996], // Somalia
|
||||
SR: [3.9193, -56.0278], // Suriname
|
||||
SS: [4.8594, 31.5713], // South Sudan
|
||||
ST: [0.1864, 6.6131], // Sao Tome and Principe
|
||||
SV: [13.7942, -88.8965], // El Salvador
|
||||
SX: [18.0425, -63.0548], // Sint Maarten
|
||||
SY: [34.8021, 38.9968], // Syria
|
||||
SZ: [-26.5225, 31.4659], // Eswatini (Swaziland)
|
||||
TC: [21.6940, -71.7979], // Turks and Caicos Islands
|
||||
TD: [15.4542, 18.7322], // Chad
|
||||
TF: [-49.2804, 69.3486], // French Southern Territories
|
||||
TG: [8.6195, 0.8248], // Togo
|
||||
TH: [15.8700, 100.9925], // Thailand
|
||||
TJ: [38.8610, 71.2761], // Tajikistan
|
||||
TK: [-9.2000, -171.8484],// Tokelau
|
||||
TL: [-8.8742, 125.7275], // Timor-Leste
|
||||
TM: [38.9697, 59.5563], // Turkmenistan
|
||||
TN: [33.8869, 9.5375], // Tunisia
|
||||
TO: [-21.1790, -175.1982],// Tonga
|
||||
TR: [38.9637, 35.2433], // Turkey
|
||||
TT: [10.6918, -61.2225], // Trinidad and Tobago
|
||||
TV: [-7.1095, 177.6493], // Tuvalu
|
||||
TW: [23.6978, 120.9605], // Taiwan
|
||||
TZ: [-6.3690, 34.8888], // Tanzania
|
||||
UA: [48.3794, 31.1656], // Ukraine
|
||||
UG: [1.3733, 32.2903], // Uganda
|
||||
UM: [19.2823, 166.6470], // United States Minor Outlying Islands
|
||||
US: [37.0902, -95.7129], // United States
|
||||
UY: [-32.5228, -55.7658],// Uruguay
|
||||
UZ: [41.3775, 64.5853], // Uzbekistan
|
||||
VA: [41.9029, 12.4534], // Vatican City
|
||||
VC: [12.9843, -61.2872], // Saint Vincent and the Grenadines
|
||||
VE: [6.4238, -66.5897], // Venezuela
|
||||
VG: [18.4207, -64.6400], // British Virgin Islands
|
||||
VI: [18.3358, -64.8963], // U.S. Virgin Islands
|
||||
VN: [14.0583, 108.2772], // Vietnam
|
||||
VU: [-15.3767, 166.9592],// Vanuatu
|
||||
WF: [-13.7687, -177.1561],// Wallis and Futuna
|
||||
WS: [-13.7590, -172.1046],// Samoa
|
||||
XK: [42.6026, 20.9030], // Kosovo
|
||||
YE: [15.5527, 48.5164], // Yemen
|
||||
YT: [-12.8275, 45.1662], // Mayotte
|
||||
ZA: [-30.5595, 22.9375], // South Africa
|
||||
ZM: [-13.1339, 27.8493], // Zambia
|
||||
ZW: [-19.0154, 29.1549], // Zimbabwe
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the [latitude, longitude] centroid for a given ISO 3166-1 alpha-2
|
||||
* country code, or null if the code is not recognised.
|
||||
*
|
||||
* The lookup is case-insensitive.
|
||||
*/
|
||||
export function countryToLatLng(code: string): [number, number] | null {
|
||||
if (!code) return null;
|
||||
const normalised = code.trim().toUpperCase();
|
||||
return COUNTRY_CENTROIDS[normalised] ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
const NAVIDROME_URL = process.env.NAVIDROME_URL || 'https://music.jefflix.lol'
|
||||
const NAVIDROME_USER = process.env.NAVIDROME_USER || ''
|
||||
const NAVIDROME_PASS = process.env.NAVIDROME_PASS || ''
|
||||
|
||||
function subsonicAuth() {
|
||||
const salt = crypto.randomBytes(6).toString('hex')
|
||||
const token = crypto.createHash('md5').update(NAVIDROME_PASS + salt).digest('hex')
|
||||
return { u: NAVIDROME_USER, t: token, s: salt, v: '1.16.1', c: 'jefflix', f: 'json' }
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params: Record<string, string> = {}) {
|
||||
const auth = subsonicAuth()
|
||||
const url = new URL(`/rest/${path}`, NAVIDROME_URL)
|
||||
for (const [k, v] of Object.entries({ ...auth, ...params })) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/** JSON response from Subsonic API (parsed) */
|
||||
export async function navidromeGet<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
const url = buildUrl(path, params)
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`)
|
||||
const json = await res.json()
|
||||
const sub = json['subsonic-response']
|
||||
if (sub?.status !== 'ok') {
|
||||
throw new Error(sub?.error?.message || `Navidrome error on ${path}`)
|
||||
}
|
||||
return sub as T
|
||||
}
|
||||
|
||||
/** Raw binary response (for streaming audio, cover art) */
|
||||
export async function navidromeFetch(
|
||||
path: string,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<Response> {
|
||||
const url = buildUrl(path, params)
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`)
|
||||
return res
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* IndexedDB wrapper for offline audio storage.
|
||||
* Two object stores:
|
||||
* - 'audio-blobs': trackId → Blob (the audio file)
|
||||
* - 'track-meta': trackId → Track metadata (for listing without loading blobs)
|
||||
*/
|
||||
|
||||
import type { Track } from '@/components/music/music-provider'
|
||||
|
||||
const DB_NAME = 'soulsync-offline'
|
||||
const DB_VERSION = 1
|
||||
const AUDIO_STORE = 'audio-blobs'
|
||||
const META_STORE = 'track-meta'
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
|
||||
db.createObjectStore(AUDIO_STORE)
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE)
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
function tx(
|
||||
db: IDBDatabase,
|
||||
stores: string | string[],
|
||||
mode: IDBTransactionMode = 'readonly'
|
||||
): IDBTransaction {
|
||||
return db.transaction(stores, mode)
|
||||
}
|
||||
|
||||
/** Save an audio blob and track metadata */
|
||||
export async function saveTrack(trackId: string, blob: Blob, meta: Track): Promise<void> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
|
||||
t.objectStore(AUDIO_STORE).put(blob, trackId)
|
||||
t.objectStore(META_STORE).put(meta, trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
t.oncomplete = () => { db.close(); resolve() }
|
||||
t.onerror = () => { db.close(); reject(t.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Get the audio blob for a track */
|
||||
export async function getTrackBlob(trackId: string): Promise<Blob | undefined> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, AUDIO_STORE)
|
||||
const req = t.objectStore(AUDIO_STORE).get(trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => { db.close(); resolve(req.result) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Check if a track is stored offline */
|
||||
export async function hasTrack(trackId: string): Promise<boolean> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, META_STORE)
|
||||
const req = t.objectStore(META_STORE).count(trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Save only the audio blob (no metadata — used for pre-caching) */
|
||||
export async function saveBlob(trackId: string, blob: Blob): Promise<void> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, AUDIO_STORE, 'readwrite')
|
||||
t.objectStore(AUDIO_STORE).put(blob, trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
t.oncomplete = () => { db.close(); resolve() }
|
||||
t.onerror = () => { db.close(); reject(t.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Check if an audio blob exists (regardless of metadata) */
|
||||
export async function hasBlob(trackId: string): Promise<boolean> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, AUDIO_STORE)
|
||||
const req = t.objectStore(AUDIO_STORE).count(trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove a track from offline storage */
|
||||
export async function removeTrack(trackId: string): Promise<void> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
|
||||
t.objectStore(AUDIO_STORE).delete(trackId)
|
||||
t.objectStore(META_STORE).delete(trackId)
|
||||
return new Promise((resolve, reject) => {
|
||||
t.oncomplete = () => { db.close(); resolve() }
|
||||
t.onerror = () => { db.close(); reject(t.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** List all offline tracks (metadata only) */
|
||||
export async function listOfflineTracks(): Promise<Track[]> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, META_STORE)
|
||||
const req = t.objectStore(META_STORE).getAll()
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => { db.close(); resolve(req.result) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Get all offline track IDs (for fast Set building) */
|
||||
export async function listOfflineIds(): Promise<string[]> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, META_STORE)
|
||||
const req = t.objectStore(META_STORE).getAllKeys()
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => { db.close(); resolve(req.result as string[]) }
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Get total storage used (sum of all blob sizes in bytes) */
|
||||
export async function getTotalSize(): Promise<number> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, AUDIO_STORE)
|
||||
const store = t.objectStore(AUDIO_STORE)
|
||||
const req = store.openCursor()
|
||||
let total = 0
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result
|
||||
if (cursor) {
|
||||
const blob = cursor.value as Blob
|
||||
total += blob.size
|
||||
cursor.continue()
|
||||
} else {
|
||||
db.close()
|
||||
resolve(total)
|
||||
}
|
||||
}
|
||||
req.onerror = () => { db.close(); reject(req.error) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove all offline data */
|
||||
export async function clearAll(): Promise<void> {
|
||||
const db = await openDB()
|
||||
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
|
||||
t.objectStore(AUDIO_STORE).clear()
|
||||
t.objectStore(META_STORE).clear()
|
||||
return new Promise((resolve, reject) => {
|
||||
t.oncomplete = () => { db.close(); resolve() }
|
||||
t.onerror = () => { db.close(); reject(t.error) }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { hasBlob, saveBlob } from '@/lib/offline-db'
|
||||
|
||||
/**
|
||||
* Pre-cache a track's audio blob into IndexedDB.
|
||||
* Only stores the blob (no metadata) so pre-cached tracks don't appear
|
||||
* in the offline library, but the player still finds them via getTrackBlob().
|
||||
* Best-effort — errors are silently swallowed.
|
||||
*/
|
||||
export async function precacheTrack(
|
||||
trackId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (await hasBlob(trackId)) return
|
||||
if (signal?.aborted) return
|
||||
|
||||
const res = await fetch(`/api/music/stream/${trackId}`, { signal })
|
||||
if (!res.ok) return
|
||||
|
||||
const blob = await res.blob()
|
||||
if (signal?.aborted) return
|
||||
|
||||
await saveBlob(trackId, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-cache the next N tracks in a queue sequentially.
|
||||
* Sequential to avoid flooding bandwidth on mobile connections.
|
||||
*/
|
||||
export async function precacheUpcoming(
|
||||
queue: { id: string }[],
|
||||
currentIndex: number,
|
||||
count: number,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const upcoming = queue.slice(currentIndex + 1, currentIndex + 1 + count)
|
||||
for (const track of upcoming) {
|
||||
if (signal.aborted) return
|
||||
try {
|
||||
await precacheTrack(track.id, signal)
|
||||
} catch {
|
||||
// best-effort — continue with next track
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
export interface RadioPlace {
|
||||
id: string
|
||||
title: string
|
||||
country: string
|
||||
lat: number
|
||||
lng: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface RadioChannel {
|
||||
id: string
|
||||
title: string
|
||||
placeTitle: string
|
||||
country: string
|
||||
website?: string
|
||||
}
|
||||
|
||||
export interface RadioSearchResult {
|
||||
stations: Array<{
|
||||
id: string
|
||||
title: string
|
||||
placeId: string
|
||||
placeTitle: string
|
||||
country: string
|
||||
}>
|
||||
places: Array<{
|
||||
id: string
|
||||
title: string
|
||||
country: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function getPlaces(): Promise<RadioPlace[]> {
|
||||
const res = await fetch('/api/radio/places')
|
||||
if (!res.ok) throw new Error('Failed to load radio places')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getChannels(placeId: string): Promise<RadioChannel[]> {
|
||||
const res = await fetch(`/api/radio/channels/${placeId}`)
|
||||
if (!res.ok) throw new Error('Failed to load channels')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function resolveStreamUrl(channelId: string): Promise<string> {
|
||||
const res = await fetch(`/api/radio/stream/${channelId}`)
|
||||
if (!res.ok) throw new Error('Failed to resolve stream')
|
||||
const data = await res.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
export async function searchRadio(q: string): Promise<RadioSearchResult> {
|
||||
const res = await fetch(`/api/radio/search?q=${encodeURIComponent(q)}`)
|
||||
if (!res.ok) throw new Error('Search failed')
|
||||
return res.json()
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
export interface SlskdRawFile {
|
||||
filename: string
|
||||
size: number
|
||||
bitRate: number
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface SlskdRawResponse {
|
||||
username: string
|
||||
files: SlskdRawFile[]
|
||||
freeUploadSlots: number
|
||||
speed: number
|
||||
}
|
||||
|
||||
export interface DedupedFile {
|
||||
displayName: string
|
||||
filename: string
|
||||
size: number
|
||||
bitRate: number
|
||||
length: number
|
||||
bestPeer: {
|
||||
username: string
|
||||
freeSlots: number
|
||||
speed: number
|
||||
}
|
||||
peerCount: number
|
||||
}
|
||||
|
||||
function normalizeName(filename: string): string {
|
||||
// Strip path separators (Windows backslash or Unix forward slash)
|
||||
const basename = filename.replace(/^.*[\\\/]/, '')
|
||||
// Strip extension
|
||||
const noExt = basename.replace(/\.[^.]+$/, '')
|
||||
return noExt.toLowerCase().trim()
|
||||
}
|
||||
|
||||
function prettyName(filename: string): string {
|
||||
return filename.replace(/^.*[\\\/]/, '').replace(/\.[^.]+$/, '')
|
||||
}
|
||||
|
||||
export function extractBestFiles(responses: SlskdRawResponse[], limit = 30): DedupedFile[] {
|
||||
const groups = new Map<string, { file: SlskdRawFile; peer: SlskdRawResponse; displayName: string }[]>()
|
||||
|
||||
for (const peer of responses) {
|
||||
if (!peer.files?.length) continue
|
||||
for (const file of peer.files) {
|
||||
const key = normalizeName(file.filename)
|
||||
if (!key) continue
|
||||
const entry = { file, peer, displayName: prettyName(file.filename) }
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.push(entry)
|
||||
} else {
|
||||
groups.set(key, [entry])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deduped: DedupedFile[] = []
|
||||
|
||||
for (const [, entries] of groups) {
|
||||
// Pick best peer: prefer freeUploadSlots > 0, then highest speed
|
||||
entries.sort((a, b) => {
|
||||
const aFree = a.peer.freeUploadSlots > 0 ? 1 : 0
|
||||
const bFree = b.peer.freeUploadSlots > 0 ? 1 : 0
|
||||
if (aFree !== bFree) return bFree - aFree
|
||||
return b.peer.speed - a.peer.speed
|
||||
})
|
||||
|
||||
const best = entries[0]
|
||||
deduped.push({
|
||||
displayName: best.displayName,
|
||||
filename: best.file.filename,
|
||||
size: best.file.size,
|
||||
bitRate: best.file.bitRate,
|
||||
length: best.file.length,
|
||||
bestPeer: {
|
||||
username: best.peer.username,
|
||||
freeSlots: best.peer.freeUploadSlots,
|
||||
speed: best.peer.speed,
|
||||
},
|
||||
peerCount: entries.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by highest bitRate first
|
||||
deduped.sort((a, b) => (b.bitRate || 0) - (a.bitRate || 0))
|
||||
|
||||
return deduped.slice(0, limit)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
const SLSKD_URL = process.env.SLSKD_URL || 'https://slskd.jefflix.lol'
|
||||
const SLSKD_API_KEY = process.env.SLSKD_API_KEY || ''
|
||||
|
||||
export async function slskdFetch(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${SLSKD_URL}/api/v0${path}`
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'X-API-Key': SLSKD_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Track } from '@/components/music/music-provider'
|
||||
import {
|
||||
saveTrack,
|
||||
removeTrack as removeFromDB,
|
||||
listOfflineIds,
|
||||
getTotalSize,
|
||||
clearAll as clearDB,
|
||||
listOfflineTracks,
|
||||
} from '@/lib/offline-db'
|
||||
|
||||
type DownloadStatus = 'idle' | 'queued' | 'downloading'
|
||||
|
||||
interface OfflineContextValue {
|
||||
offlineIds: Set<string>
|
||||
queue: Track[]
|
||||
activeDownloadId: string | null
|
||||
storageUsed: number
|
||||
download: (track: Track) => void
|
||||
remove: (trackId: string) => void
|
||||
clearAll: () => void
|
||||
getStatus: (trackId: string) => DownloadStatus
|
||||
sync: () => Promise<void>
|
||||
offlineTracks: Track[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const OfflineContext = createContext<OfflineContextValue | null>(null)
|
||||
|
||||
export function useOffline() {
|
||||
const ctx = useContext(OfflineContext)
|
||||
if (!ctx) throw new Error('useOffline must be used within OfflineProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||
const [offlineIds, setOfflineIds] = useState<Set<string>>(new Set())
|
||||
const [offlineTracks, setOfflineTracks] = useState<Track[]>([])
|
||||
const [queue, setQueue] = useState<Track[]>([])
|
||||
const [activeDownloadId, setActiveDownloadId] = useState<string | null>(null)
|
||||
const [storageUsed, setStorageUsed] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const processingRef = useRef(false)
|
||||
// Mirror queue in a ref for access in async loops
|
||||
const queueRef = useRef<Track[]>([])
|
||||
|
||||
const refreshLocal = useCallback(async () => {
|
||||
const [ids, tracks, size] = await Promise.all([
|
||||
listOfflineIds(),
|
||||
listOfflineTracks(),
|
||||
getTotalSize(),
|
||||
])
|
||||
setOfflineIds(new Set(ids))
|
||||
setOfflineTracks(tracks)
|
||||
setStorageUsed(size)
|
||||
}, [])
|
||||
|
||||
const updateQueue = useCallback((updater: (prev: Track[]) => Track[]) => {
|
||||
setQueue((prev) => {
|
||||
const next = updater(prev)
|
||||
queueRef.current = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
const nextTrack = queueRef.current[0]
|
||||
setActiveDownloadId(nextTrack.id)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/music/stream/${nextTrack.id}`)
|
||||
if (!res.ok) throw new Error(`Stream failed: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
|
||||
await saveTrack(nextTrack.id, blob, nextTrack)
|
||||
|
||||
// Sync to server playlist (non-critical)
|
||||
await fetch('/api/music/offline', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId: nextTrack.id }),
|
||||
}).catch(() => {})
|
||||
|
||||
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
|
||||
await refreshLocal()
|
||||
} catch (err) {
|
||||
console.error(`Failed to download ${nextTrack.id}:`, err)
|
||||
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
|
||||
}
|
||||
}
|
||||
|
||||
setActiveDownloadId(null)
|
||||
processingRef.current = false
|
||||
}, [refreshLocal, updateQueue])
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/music/offline')
|
||||
if (!res.ok) return
|
||||
const { songs } = await res.json() as { songs: Track[] }
|
||||
|
||||
const localIds = await listOfflineIds()
|
||||
const localSet = new Set(localIds)
|
||||
const serverIds = new Set(songs.map((s: Track) => s.id))
|
||||
|
||||
// Download songs on server but not local
|
||||
const toDownload = songs.filter((s: Track) => !localSet.has(s.id))
|
||||
if (toDownload.length > 0) {
|
||||
updateQueue((prev) => {
|
||||
const existingIds = new Set(prev.map((t) => t.id))
|
||||
return [...prev, ...toDownload.filter((t: Track) => !existingIds.has(t.id))]
|
||||
})
|
||||
}
|
||||
|
||||
// Remove local songs deleted from server
|
||||
for (const localId of localIds) {
|
||||
if (!serverIds.has(localId)) {
|
||||
await removeFromDB(localId)
|
||||
}
|
||||
}
|
||||
|
||||
await refreshLocal()
|
||||
} catch (err) {
|
||||
console.error('Offline sync error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [refreshLocal, updateQueue])
|
||||
|
||||
// Initial load + sync
|
||||
useEffect(() => {
|
||||
refreshLocal().then(() => sync())
|
||||
}, [refreshLocal, sync])
|
||||
|
||||
// Process queue when items are added
|
||||
useEffect(() => {
|
||||
if (queue.length > 0 && !processingRef.current) {
|
||||
processQueue()
|
||||
}
|
||||
}, [queue, processQueue])
|
||||
|
||||
const download = useCallback((track: Track) => {
|
||||
updateQueue((prev) => {
|
||||
if (prev.some((t) => t.id === track.id)) return prev
|
||||
return [...prev, track]
|
||||
})
|
||||
}, [updateQueue])
|
||||
|
||||
const remove = useCallback(async (trackId: string) => {
|
||||
await removeFromDB(trackId)
|
||||
await fetch('/api/music/offline', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId: trackId }),
|
||||
}).catch(() => {})
|
||||
await refreshLocal()
|
||||
}, [refreshLocal])
|
||||
|
||||
const clearAllOffline = useCallback(async () => {
|
||||
await clearDB()
|
||||
await refreshLocal()
|
||||
}, [refreshLocal])
|
||||
|
||||
const getStatus = useCallback((trackId: string): DownloadStatus => {
|
||||
if (offlineIds.has(trackId)) return 'idle'
|
||||
if (activeDownloadId === trackId) return 'downloading'
|
||||
if (queue.some((t) => t.id === trackId)) return 'queued'
|
||||
return 'idle'
|
||||
}, [offlineIds, activeDownloadId, queue])
|
||||
|
||||
return (
|
||||
<OfflineContext.Provider value={{
|
||||
offlineIds,
|
||||
queue,
|
||||
activeDownloadId,
|
||||
storageUsed,
|
||||
download,
|
||||
remove,
|
||||
clearAll: clearAllOffline,
|
||||
getStatus,
|
||||
sync,
|
||||
offlineTracks,
|
||||
loading,
|
||||
}}>
|
||||
{children}
|
||||
</OfflineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import WebSocket from 'ws'
|
||||
|
||||
interface ThreadfinConfig {
|
||||
url: string
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
|
||||
interface XepgEntry {
|
||||
'tvg-id': string
|
||||
'x-active': boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type EpgMapping = Record<string, XepgEntry>
|
||||
|
||||
interface ActivateResult {
|
||||
activated: string[]
|
||||
notFound: string[]
|
||||
}
|
||||
|
||||
function getConfig(): ThreadfinConfig {
|
||||
const url = process.env.THREADFIN_URL
|
||||
const user = process.env.THREADFIN_USER
|
||||
const pass = process.env.THREADFIN_PASS
|
||||
if (!url || !user || !pass) {
|
||||
throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set')
|
||||
}
|
||||
return { url: url.replace(/\/$/, ''), user, pass }
|
||||
}
|
||||
|
||||
async function login(): Promise<string> {
|
||||
const { url, user, pass } = getConfig()
|
||||
const res = await fetch(`${url}/api/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cmd: 'login', username: user, password: pass }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.token) throw new Error('Threadfin login returned no token')
|
||||
return data.token
|
||||
}
|
||||
|
||||
export async function activateChannels(
|
||||
channelIds: string[],
|
||||
): Promise<ActivateResult> {
|
||||
const { url } = getConfig()
|
||||
const token = await login()
|
||||
|
||||
const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
|
||||
|
||||
return new Promise<ActivateResult>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error('Threadfin WebSocket timed out after 30s'))
|
||||
}, 30_000)
|
||||
|
||||
let currentToken = token
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString())
|
||||
|
||||
// Update token if rotated
|
||||
if (msg.token) currentToken = msg.token
|
||||
|
||||
// Initial data response contains the xepg map
|
||||
if (msg.xepg) {
|
||||
const epgMapping: EpgMapping = msg.xepg
|
||||
|
||||
const activated: string[] = []
|
||||
const idSet = new Set(channelIds)
|
||||
const foundIds = new Set<string>()
|
||||
|
||||
// Find and activate matching channels
|
||||
for (const [key, entry] of Object.entries(epgMapping)) {
|
||||
const tvgId = entry['tvg-id']
|
||||
if (tvgId && idSet.has(tvgId)) {
|
||||
entry['x-active'] = true
|
||||
activated.push(tvgId)
|
||||
foundIds.add(tvgId)
|
||||
}
|
||||
}
|
||||
|
||||
const notFound = channelIds.filter((id) => !foundIds.has(id))
|
||||
|
||||
if (activated.length === 0) {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve({ activated, notFound })
|
||||
return
|
||||
}
|
||||
|
||||
// Must send the ENTIRE map back
|
||||
const saveMsg = JSON.stringify({
|
||||
cmd: 'saveEpgMapping',
|
||||
epgMapping,
|
||||
token: currentToken,
|
||||
})
|
||||
ws.send(saveMsg, (err) => {
|
||||
clearTimeout(timeout)
|
||||
if (err) {
|
||||
ws.close()
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
// Give Threadfin a moment to process, then close
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve({ activated, notFound })
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { createHmac } from 'crypto'
|
||||
|
||||
interface ApprovePayload {
|
||||
channels: { id: string; name: string }[]
|
||||
email: string
|
||||
exp: number
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function getSecret(): string {
|
||||
const secret = process.env.TOKEN_SECRET
|
||||
if (!secret) throw new Error('TOKEN_SECRET env var is not set')
|
||||
return secret
|
||||
}
|
||||
|
||||
export function createApproveToken(
|
||||
channels: { id: string; name: string }[],
|
||||
email: string,
|
||||
): string {
|
||||
const payload: ApprovePayload = {
|
||||
channels,
|
||||
email,
|
||||
exp: Date.now() + SEVEN_DAYS_MS,
|
||||
}
|
||||
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const sig = createHmac('sha256', getSecret()).update(data).digest('hex')
|
||||
return `${data}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyApproveToken(token: string): ApprovePayload | null {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 2) return null
|
||||
|
||||
const [data, sig] = parts
|
||||
const expected = createHmac('sha256', getSecret()).update(data).digest('hex')
|
||||
|
||||
// Constant-time comparison
|
||||
if (sig.length !== expected.length) return null
|
||||
let mismatch = 0
|
||||
for (let i = 0; i < sig.length; i++) {
|
||||
mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i)
|
||||
}
|
||||
if (mismatch !== 0) return null
|
||||
|
||||
try {
|
||||
const payload: ApprovePayload = JSON.parse(
|
||||
Buffer.from(data, 'base64url').toString('utf-8'),
|
||||
)
|
||||
|
||||
if (!payload.exp || !payload.channels || !payload.email) return null
|
||||
if (Date.now() > payload.exp) return null
|
||||
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const hasAccess = request.cookies.get("jefflix-access")?.value === "granted"
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.redirect(new URL("/gate", request.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all paths except:
|
||||
* - /gate (the code entry page)
|
||||
* - /api/verify-code (the verification endpoint)
|
||||
* - /_next (Next.js internals)
|
||||
* - /favicon.ico, /icon*, /apple-icon*, /og-image* (static assets)
|
||||
*/
|
||||
"/((?!gate|api/verify-code|api/version|_next|favicon\\.ico|icon|apple-icon|og-image|manifest\\.json|sw\\.js|textures/).*)",
|
||||
],
|
||||
}
|
||||
|
|
@ -7,6 +7,18 @@ const nextConfig = {
|
|||
unoptimized: true,
|
||||
},
|
||||
output: 'standalone',
|
||||
env: {
|
||||
BUILD_ID: process.env.BUILD_ID || new Date().toISOString(),
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/invite',
|
||||
destination: 'https://invite.jefflix.lol',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -37,7 +37,6 @@
|
|||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@vercel/analytics": "latest",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -46,11 +45,13 @@
|
|||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "16.0.3",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.1",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-globe.gl": "^2.37.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
|
|
@ -58,16 +59,19 @@
|
|||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/ws": "^8.18.1",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1846
pnpm-lock.yaml
1846
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,26 +1,3 @@
|
|||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.background { fill: black; }
|
||||
.foreground { fill: white; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.background { fill: white; }
|
||||
.foreground { fill: black; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_7960_43945)">
|
||||
<rect class="background" width="180" height="180" rx="37" />
|
||||
<g style="transform: scale(95%); transform-origin: center">
|
||||
<path class="foreground"
|
||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||
<path class="foreground"
|
||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7960_43945">
|
||||
<rect width="180" height="180" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y="0.9em" font-size="90">🤣</text>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 115 B |
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "Jefflix",
|
||||
"short_name": "Jefflix",
|
||||
"description": "Self-hosted media streaming — movies, TV, music, and world radio",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"orientation": "any",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-light-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/apple-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Music",
|
||||
"short_name": "Music",
|
||||
"url": "/music",
|
||||
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
|
||||
},
|
||||
{
|
||||
"name": "World Radio",
|
||||
"short_name": "Radio",
|
||||
"url": "/radio",
|
||||
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
|
||||
},
|
||||
{
|
||||
"name": "Offline Library",
|
||||
"short_name": "Offline",
|
||||
"url": "/offline",
|
||||
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
|
||||
}
|
||||
],
|
||||
"categories": ["entertainment", "music"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
|
|
@ -0,0 +1,116 @@
|
|||
/// <reference lib="webworker" />
|
||||
|
||||
const CACHE_NAME = 'soulsync-shell-v2'
|
||||
const DB_NAME = 'soulsync-offline'
|
||||
const AUDIO_STORE = 'audio-blobs'
|
||||
|
||||
// App shell files to cache for offline UI access
|
||||
const SHELL_FILES = [
|
||||
'/',
|
||||
'/music',
|
||||
'/radio',
|
||||
'/offline',
|
||||
]
|
||||
|
||||
// Install: pre-cache app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) =>
|
||||
cache.addAll(SHELL_FILES).catch(() => {
|
||||
// Non-critical if some pages fail to cache
|
||||
})
|
||||
)
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME && k.startsWith('soulsync-')).map((k) => caches.delete(k)))
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
/**
|
||||
* Open IndexedDB from the service worker to serve cached audio
|
||||
*/
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
|
||||
db.createObjectStore(AUDIO_STORE)
|
||||
}
|
||||
if (!db.objectStoreNames.contains('track-meta')) {
|
||||
db.createObjectStore('track-meta')
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
function getFromDB(trackId) {
|
||||
return openDB().then(
|
||||
(db) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(AUDIO_STORE, 'readonly')
|
||||
const req = tx.objectStore(AUDIO_STORE).get(trackId)
|
||||
req.onsuccess = () => {
|
||||
db.close()
|
||||
resolve(req.result)
|
||||
}
|
||||
req.onerror = () => {
|
||||
db.close()
|
||||
reject(req.error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Listen for SKIP_WAITING message from the app to activate a waiting SW
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch: intercept /api/music/stream/ requests to serve from IndexedDB
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Intercept stream requests
|
||||
const streamMatch = url.pathname.match(/^\/api\/music\/stream\/(.+)$/)
|
||||
if (streamMatch) {
|
||||
const trackId = streamMatch[1]
|
||||
event.respondWith(
|
||||
getFromDB(trackId).then((blob) => {
|
||||
if (blob) {
|
||||
return new Response(blob, {
|
||||
headers: {
|
||||
'Content-Type': blob.type || 'audio/mpeg',
|
||||
'Content-Length': String(blob.size),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Not cached, fetch from network
|
||||
return fetch(event.request)
|
||||
}).catch(() => fetch(event.request))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// For navigation requests: try network first, fall back to cache
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() =>
|
||||
caches.match(event.request).then((cached) => cached || caches.match('/'))
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -0,0 +1,76 @@
|
|||
services:
|
||||
# slskd - Soulseek client with REST API
|
||||
slskd:
|
||||
image: slskd/slskd:latest
|
||||
container_name: slskd
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SLSKD_REMOTE_CONFIGURATION=true
|
||||
- SLSKD_SHARED_DIR=/shared
|
||||
- SLSKD_DOWNLOADS_DIR=/downloads
|
||||
- SLSKD_NO_HTTPS=true
|
||||
- TZ=Europe/Berlin
|
||||
volumes:
|
||||
- ./config/slskd:/app
|
||||
- ./downloads:/downloads
|
||||
# Share some of your music library back (important - avoid bans!)
|
||||
- /mnt/hetzner-media/media/music:/shared:ro
|
||||
# Transfer folder - completed downloads move here for import
|
||||
- ./transfer:/transfer
|
||||
ports:
|
||||
- "50300:50300" # Soulseek listen port
|
||||
- "50300:50300/udp"
|
||||
networks:
|
||||
- soulsync-network
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.slskd.rule=Host(`soulseek.jefflix.lol`) || Host(`slskd.jefflix.lol`)"
|
||||
- "traefik.http.routers.slskd.entrypoints=web"
|
||||
- "traefik.http.services.slskd.loadbalancer.server.port=5030"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
# SoulSync - Spotify/Tidal to Soulseek sync
|
||||
soulsync:
|
||||
image: boulderbadgedad/soulsync:latest
|
||||
container_name: soulsync
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- PYTHONPATH=/app
|
||||
- FLASK_ENV=production
|
||||
volumes:
|
||||
- ./config/soulsync:/app/data
|
||||
- ./config/soulsync/logs:/app/logs
|
||||
- ./config/soulsync/config:/app/config
|
||||
- ./config/soulsync/storage:/app/storage
|
||||
# Access to existing music library for comparison
|
||||
- /mnt/hetzner-media/media/music:/music:ro
|
||||
# Downloads from slskd
|
||||
- ./downloads:/downloads
|
||||
# Transfer folder for completed/processed files
|
||||
- ./transfer:/transfer
|
||||
depends_on:
|
||||
- slskd
|
||||
networks:
|
||||
- soulsync-network
|
||||
- traefik-public
|
||||
- media-network
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.soulsync.rule=Host(`soulsync.jefflix.lol`)"
|
||||
- "traefik.http.routers.soulsync.entrypoints=web"
|
||||
- "traefik.http.services.soulsync.loadbalancer.server.port=8008"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
networks:
|
||||
soulsync-network:
|
||||
driver: bridge
|
||||
traefik-public:
|
||||
external: true
|
||||
media-network:
|
||||
external: true
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
|
|
@ -11,7 +15,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -19,9 +23,19 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue