mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
Wireframe background, unified loading, admin format enforcement
Wireframe background: - Canvas-based constellation animation — 45 floating nodes connected by proximity lines, subtle pulsing glow on select nodes - Blue primary + orange accent line colors match cyberpunk palette - Pauses on tab hidden, respects devicePixelRatio, ~0% CPU idle - Only renders when cyberpunk theme is active (v-if on theme) - Replaces CSS-only diagonal lines/pulse (removed from cyberpunk.css) Unified URL analysis: - Merged 'Checking URL...' and 'Extracting available formats...' into a single loading state with rotating messages: 'Peeking at the URL', 'Interrogating the server', 'Decoding the matrix', etc. - Both fetches run in parallel via Promise.all, single spinner shown - Phase messages rotate every 1.5s during analysis Admin format enforcement: - Backend PUT /admin/settings now accepts default_video_format and default_audio_format fields with validation - Stored in settings_overrides alongside welcome_message - UrlInput reads admin defaults from config store — Auto label shows 'Auto (.mp3)' etc. when admin has set a default - effectiveOutputFormat computed resolves admin default when user selects Auto, sends the resolved format to the backend
This commit is contained in:
parent
44eb8c758a
commit
635da2be82
5 changed files with 254 additions and 79 deletions
|
|
@ -168,6 +168,8 @@ async def update_settings(
|
|||
|
||||
Accepts a JSON body with optional fields:
|
||||
- welcome_message: str
|
||||
- default_video_format: str (auto, mp4, webm)
|
||||
- default_audio_format: str (auto, mp3, m4a, flac, wav, opus)
|
||||
"""
|
||||
body = await request.json()
|
||||
|
||||
|
|
@ -188,4 +190,21 @@ async def update_settings(
|
|||
updated.append("welcome_message")
|
||||
logger.info("Admin updated welcome_message to: %s", msg[:80])
|
||||
|
||||
valid_video_formats = {"auto", "mp4", "webm"}
|
||||
valid_audio_formats = {"auto", "mp3", "m4a", "flac", "wav", "opus"}
|
||||
|
||||
if "default_video_format" in body:
|
||||
fmt = body["default_video_format"]
|
||||
if fmt in valid_video_formats:
|
||||
request.app.state.settings_overrides["default_video_format"] = fmt
|
||||
updated.append("default_video_format")
|
||||
logger.info("Admin updated default_video_format to: %s", fmt)
|
||||
|
||||
if "default_audio_format" in body:
|
||||
fmt = body["default_audio_format"]
|
||||
if fmt in valid_audio_formats:
|
||||
request.app.state.settings_overrides["default_audio_format"] = fmt
|
||||
updated.append("default_audio_format")
|
||||
logger.info("Admin updated default_audio_format to: %s", fmt)
|
||||
|
||||
return {"updated": updated, "status": "ok"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import WireframeBackground from './WireframeBackground.vue'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
|
||||
|
||||
type MobileTab = 'submit' | 'queue'
|
||||
const activeTab = ref<MobileTab>('submit')
|
||||
|
|
@ -7,6 +12,7 @@ const activeTab = ref<MobileTab>('submit')
|
|||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<WireframeBackground v-if="showWireframe" />
|
||||
<!-- Desktop: single scrollable view -->
|
||||
<main class="layout-main">
|
||||
<!-- URL input section -->
|
||||
|
|
@ -58,6 +64,8 @@ const activeTab = ref<MobileTab>('submit')
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-submit,
|
||||
|
|
|
|||
|
|
@ -12,15 +12,42 @@ const configStore = useConfigStore()
|
|||
const url = ref('')
|
||||
const formats = ref<FormatInfo[]>([])
|
||||
const selectedFormatId = ref<string | null>(null)
|
||||
const isExtracting = ref(false)
|
||||
const extractError = ref<string | null>(null)
|
||||
const showOptions = ref(false)
|
||||
|
||||
// URL preview state
|
||||
const urlInfo = ref<UrlInfo | null>(null)
|
||||
const isLoadingInfo = ref(false)
|
||||
const audioLocked = ref(false) // true when source is audio-only
|
||||
|
||||
// Unified loading state for URL check + format extraction
|
||||
const isAnalyzing = ref(false)
|
||||
const analyzePhase = ref<string>('')
|
||||
const phaseMessages = [
|
||||
'Peeking at the URL…',
|
||||
'Interrogating the server…',
|
||||
'Decoding the matrix…',
|
||||
'Sniffing out formats…',
|
||||
'Almost there…',
|
||||
]
|
||||
let phaseTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startAnalyzePhase(): void {
|
||||
let idx = 0
|
||||
analyzePhase.value = phaseMessages[0]
|
||||
phaseTimer = setInterval(() => {
|
||||
idx = Math.min(idx + 1, phaseMessages.length - 1)
|
||||
analyzePhase.value = phaseMessages[idx]
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function stopAnalyzePhase(): void {
|
||||
if (phaseTimer) {
|
||||
clearInterval(phaseTimer)
|
||||
phaseTimer = null
|
||||
}
|
||||
analyzePhase.value = ''
|
||||
}
|
||||
|
||||
type MediaType = 'video' | 'audio'
|
||||
const mediaType = ref<MediaType>(
|
||||
(localStorage.getItem('mediarip:mediaType') as MediaType) || 'video'
|
||||
|
|
@ -92,7 +119,6 @@ async function extractFormats(): Promise<void> {
|
|||
const trimmed = url.value.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
isExtracting.value = true
|
||||
extractError.value = null
|
||||
formats.value = []
|
||||
selectedFormatId.value = null
|
||||
|
|
@ -101,8 +127,6 @@ async function extractFormats(): Promise<void> {
|
|||
formats.value = await api.getFormats(trimmed)
|
||||
} catch (err: any) {
|
||||
extractError.value = err.message || 'Failed to extract formats'
|
||||
} finally {
|
||||
isExtracting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,11 +176,17 @@ function onFormatSelect(formatId: string | null): void {
|
|||
}
|
||||
|
||||
function handlePaste(): void {
|
||||
// Auto-extract on paste (populate formats + URL info silently in background)
|
||||
setTimeout(() => {
|
||||
// Auto-extract on paste — unified loading state
|
||||
setTimeout(async () => {
|
||||
if (url.value.trim()) {
|
||||
extractFormats()
|
||||
fetchUrlInfo()
|
||||
isAnalyzing.value = true
|
||||
startAnalyzePhase()
|
||||
try {
|
||||
await Promise.all([extractFormats(), fetchUrlInfo()])
|
||||
} finally {
|
||||
isAnalyzing.value = false
|
||||
stopAnalyzePhase()
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
|
@ -164,7 +194,6 @@ function handlePaste(): void {
|
|||
async function fetchUrlInfo(): Promise<void> {
|
||||
const trimmed = url.value.trim()
|
||||
if (!trimmed) return
|
||||
isLoadingInfo.value = true
|
||||
urlInfo.value = null
|
||||
audioLocked.value = false
|
||||
selectedEntries.value = new Set()
|
||||
|
|
@ -183,8 +212,6 @@ async function fetchUrlInfo(): Promise<void> {
|
|||
}
|
||||
} catch {
|
||||
// Non-critical — preview is optional
|
||||
} finally {
|
||||
isLoadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +249,7 @@ function formatDuration(seconds: number | null): string {
|
|||
function toggleOptions(): void {
|
||||
showOptions.value = !showOptions.value
|
||||
// Extract formats when opening options if we haven't yet
|
||||
if (showOptions.value && !formatsReady.value && url.value.trim() && !isExtracting.value) {
|
||||
if (showOptions.value && !formatsReady.value && url.value.trim() && !isAnalyzing.value) {
|
||||
extractFormats()
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +285,7 @@ function formatTooltip(fmt: string): string {
|
|||
class="url-field"
|
||||
@paste="handlePaste"
|
||||
@keydown.enter="submitDownload"
|
||||
:disabled="isExtracting || store.isSubmitting"
|
||||
:disabled="isAnalyzing || store.isSubmitting"
|
||||
/>
|
||||
|
||||
<!-- Action row: gear, media toggle, download button -->
|
||||
|
|
@ -304,9 +331,10 @@ function formatTooltip(fmt: string): string {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingInfo" class="url-preview loading">
|
||||
<!-- Unified analyzing state -->
|
||||
<div v-if="isAnalyzing" class="url-preview loading">
|
||||
<span class="spinner"></span>
|
||||
Checking URL…
|
||||
{{ analyzePhase }}
|
||||
</div>
|
||||
|
||||
<!-- URL preview: show what will be downloaded -->
|
||||
|
|
@ -350,11 +378,6 @@ function formatTooltip(fmt: string): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExtracting" class="extract-loading">
|
||||
<span class="spinner"></span>
|
||||
Extracting available formats…
|
||||
</div>
|
||||
|
||||
<div v-if="extractError" class="extract-error">
|
||||
{{ extractError }}
|
||||
</div>
|
||||
|
|
@ -390,7 +413,7 @@ function formatTooltip(fmt: string): string {
|
|||
:media-type="mediaType"
|
||||
@select="onFormatSelect"
|
||||
/>
|
||||
<div v-else-if="!isExtracting" class="options-hint">
|
||||
<div v-else-if="!isAnalyzing" class="options-hint">
|
||||
Paste a URL and formats will load automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -505,15 +528,6 @@ button:disabled {
|
|||
}
|
||||
|
||||
/* Loading / errors */
|
||||
.extract-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
|
|
|
|||
181
frontend/src/components/WireframeBackground.vue
Normal file
181
frontend/src/components/WireframeBackground.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* WireframeBackground — animated constellation/wireframe canvas.
|
||||
*
|
||||
* Floating nodes drift slowly, connected by fading lines when
|
||||
* within proximity. Purely decorative, pointer-events: none.
|
||||
*
|
||||
* Performance: ~40 nodes, 60fps via requestAnimationFrame,
|
||||
* resolution-aware (respects devicePixelRatio), pauses when
|
||||
* tab is hidden. Typical GPU: <1% usage.
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
interface Node {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
radius: number
|
||||
pulseOffset: number
|
||||
}
|
||||
|
||||
const NODE_COUNT = 45
|
||||
const CONNECTION_DISTANCE = 180
|
||||
const NODE_SPEED = 0.3
|
||||
const LINE_OPACITY = 0.12
|
||||
const NODE_OPACITY = 0.25
|
||||
|
||||
let nodes: Node[] = []
|
||||
let animFrame = 0
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let w = 0
|
||||
let h = 0
|
||||
let dpr = 1
|
||||
|
||||
function initNodes(): void {
|
||||
nodes = []
|
||||
for (let i = 0; i < NODE_COUNT; i++) {
|
||||
nodes.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * NODE_SPEED,
|
||||
vy: (Math.random() - 0.5) * NODE_SPEED,
|
||||
radius: 1 + Math.random() * 1.5,
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
if (!canvas.value) return
|
||||
dpr = window.devicePixelRatio || 1
|
||||
w = window.innerWidth
|
||||
h = window.innerHeight
|
||||
canvas.value.width = w * dpr
|
||||
canvas.value.height = h * dpr
|
||||
canvas.value.style.width = w + 'px'
|
||||
canvas.value.style.height = h + 'px'
|
||||
ctx = canvas.value.getContext('2d')
|
||||
if (ctx) ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
function draw(time: number): void {
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Theme colors
|
||||
const isCyberpunk = themeStore.currentTheme === 'cyberpunk'
|
||||
const primaryR = isCyberpunk ? 0 : 100
|
||||
const primaryG = isCyberpunk ? 168 : 100
|
||||
const primaryB = isCyberpunk ? 255 : 100
|
||||
const accentR = isCyberpunk ? 255 : 150
|
||||
const accentG = isCyberpunk ? 107 : 150
|
||||
const accentB = isCyberpunk ? 43 : 150
|
||||
|
||||
// Update positions
|
||||
for (const node of nodes) {
|
||||
node.x += node.vx
|
||||
node.y += node.vy
|
||||
|
||||
// Wrap around edges with padding
|
||||
if (node.x < -20) node.x = w + 20
|
||||
if (node.x > w + 20) node.x = -20
|
||||
if (node.y < -20) node.y = h + 20
|
||||
if (node.y > h + 20) node.y = -20
|
||||
}
|
||||
|
||||
// Draw connections
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x
|
||||
const dy = nodes[i].y - nodes[j].y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (dist < CONNECTION_DISTANCE) {
|
||||
const alpha = (1 - dist / CONNECTION_DISTANCE) * LINE_OPACITY
|
||||
// Alternate between primary and accent color for some lines
|
||||
const useAccent = (i + j) % 7 === 0
|
||||
const r = useAccent ? accentR : primaryR
|
||||
const g = useAccent ? accentG : primaryG
|
||||
const b = useAccent ? accentB : primaryB
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y)
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y)
|
||||
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes with subtle pulse
|
||||
const pulse = Math.sin(time * 0.001) * 0.5 + 0.5
|
||||
for (const node of nodes) {
|
||||
const nodePulse = Math.sin(time * 0.002 + node.pulseOffset) * 0.3 + 0.7
|
||||
const r = node.radius * nodePulse
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(${primaryR},${primaryG},${primaryB},${NODE_OPACITY * nodePulse})`
|
||||
ctx.fill()
|
||||
|
||||
// Occasional glow on some nodes
|
||||
if (node.pulseOffset > 5) {
|
||||
const glowAlpha = pulse * 0.08
|
||||
ctx.beginPath()
|
||||
ctx.arc(node.x, node.y, r * 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(${primaryR},${primaryG},${primaryB},${glowAlpha})`
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
animFrame = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
function handleVisibility(): void {
|
||||
if (document.hidden) {
|
||||
cancelAnimationFrame(animFrame)
|
||||
} else {
|
||||
animFrame = requestAnimationFrame(draw)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resize()
|
||||
initNodes()
|
||||
animFrame = requestAnimationFrame(draw)
|
||||
window.addEventListener('resize', resize)
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animFrame)
|
||||
window.removeEventListener('resize', resize)
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
})
|
||||
|
||||
// Re-init if theme changes (colors update automatically in draw)
|
||||
watch(() => themeStore.currentTheme, () => {
|
||||
// No action needed — draw() reads theme each frame
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="wireframe-bg" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wireframe-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -79,50 +79,3 @@
|
|||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Animated geometric background — cyberpunk only */
|
||||
:root[data-theme="cyberpunk"] body::after {
|
||||
background:
|
||||
/* Diagonal lines moving slowly */
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 60px,
|
||||
rgba(0, 168, 255, 0.015) 60px,
|
||||
rgba(0, 168, 255, 0.015) 61px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 80px,
|
||||
rgba(255, 107, 43, 0.01) 80px,
|
||||
rgba(255, 107, 43, 0.01) 81px
|
||||
),
|
||||
/* Base grid */
|
||||
linear-gradient(rgba(0, 168, 255, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 168, 255, 0.025) 1px, transparent 1px);
|
||||
background-size: 200px 200px, 240px 240px, 32px 32px, 32px 32px;
|
||||
animation: grid-drift 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes grid-drift {
|
||||
0% {
|
||||
background-position: 0 0, 0 0, 0 0, 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200px 200px, -240px 240px, 32px 32px, 32px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle pulsing glow dot at intersections — extra depth */
|
||||
:root[data-theme="cyberpunk"] body {
|
||||
background-image:
|
||||
radial-gradient(circle at 50% 50%, rgba(0, 168, 255, 0.04) 0%, transparent 70%);
|
||||
background-size: 100% 100%;
|
||||
animation: bg-pulse 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes bg-pulse {
|
||||
0% { background-size: 100% 100%; }
|
||||
100% { background-size: 120% 120%; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue