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:
xpltd 2026-03-19 04:31:38 -05:00
parent 44eb8c758a
commit 635da2be82
5 changed files with 254 additions and 79 deletions

View file

@ -168,6 +168,8 @@ async def update_settings(
Accepts a JSON body with optional fields: Accepts a JSON body with optional fields:
- welcome_message: str - welcome_message: str
- default_video_format: str (auto, mp4, webm)
- default_audio_format: str (auto, mp3, m4a, flac, wav, opus)
""" """
body = await request.json() body = await request.json()
@ -188,4 +190,21 @@ async def update_settings(
updated.append("welcome_message") updated.append("welcome_message")
logger.info("Admin updated welcome_message to: %s", msg[:80]) 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"} return {"updated": updated, "status": "ok"}

View file

@ -1,5 +1,10 @@
<script setup lang="ts"> <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' type MobileTab = 'submit' | 'queue'
const activeTab = ref<MobileTab>('submit') const activeTab = ref<MobileTab>('submit')
@ -7,6 +12,7 @@ const activeTab = ref<MobileTab>('submit')
<template> <template>
<div class="app-layout"> <div class="app-layout">
<WireframeBackground v-if="showWireframe" />
<!-- Desktop: single scrollable view --> <!-- Desktop: single scrollable view -->
<main class="layout-main"> <main class="layout-main">
<!-- URL input section --> <!-- URL input section -->
@ -58,6 +64,8 @@ const activeTab = ref<MobileTab>('submit')
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xl); gap: var(--space-xl);
position: relative;
z-index: 1;
} }
.section-submit, .section-submit,

View file

@ -12,15 +12,42 @@ const configStore = useConfigStore()
const url = ref('') const url = ref('')
const formats = ref<FormatInfo[]>([]) const formats = ref<FormatInfo[]>([])
const selectedFormatId = ref<string | null>(null) const selectedFormatId = ref<string | null>(null)
const isExtracting = ref(false)
const extractError = ref<string | null>(null) const extractError = ref<string | null>(null)
const showOptions = ref(false) const showOptions = ref(false)
// URL preview state // URL preview state
const urlInfo = ref<UrlInfo | null>(null) const urlInfo = ref<UrlInfo | null>(null)
const isLoadingInfo = ref(false)
const audioLocked = ref(false) // true when source is audio-only 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' type MediaType = 'video' | 'audio'
const mediaType = ref<MediaType>( const mediaType = ref<MediaType>(
(localStorage.getItem('mediarip:mediaType') as MediaType) || 'video' (localStorage.getItem('mediarip:mediaType') as MediaType) || 'video'
@ -92,7 +119,6 @@ async function extractFormats(): Promise<void> {
const trimmed = url.value.trim() const trimmed = url.value.trim()
if (!trimmed) return if (!trimmed) return
isExtracting.value = true
extractError.value = null extractError.value = null
formats.value = [] formats.value = []
selectedFormatId.value = null selectedFormatId.value = null
@ -101,8 +127,6 @@ async function extractFormats(): Promise<void> {
formats.value = await api.getFormats(trimmed) formats.value = await api.getFormats(trimmed)
} catch (err: any) { } catch (err: any) {
extractError.value = err.message || 'Failed to extract formats' 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 { function handlePaste(): void {
// Auto-extract on paste (populate formats + URL info silently in background) // Auto-extract on paste unified loading state
setTimeout(() => { setTimeout(async () => {
if (url.value.trim()) { if (url.value.trim()) {
extractFormats() isAnalyzing.value = true
fetchUrlInfo() startAnalyzePhase()
try {
await Promise.all([extractFormats(), fetchUrlInfo()])
} finally {
isAnalyzing.value = false
stopAnalyzePhase()
}
} }
}, 50) }, 50)
} }
@ -164,7 +194,6 @@ function handlePaste(): void {
async function fetchUrlInfo(): Promise<void> { async function fetchUrlInfo(): Promise<void> {
const trimmed = url.value.trim() const trimmed = url.value.trim()
if (!trimmed) return if (!trimmed) return
isLoadingInfo.value = true
urlInfo.value = null urlInfo.value = null
audioLocked.value = false audioLocked.value = false
selectedEntries.value = new Set() selectedEntries.value = new Set()
@ -183,8 +212,6 @@ async function fetchUrlInfo(): Promise<void> {
} }
} catch { } catch {
// Non-critical preview is optional // Non-critical preview is optional
} finally {
isLoadingInfo.value = false
} }
} }
@ -222,7 +249,7 @@ function formatDuration(seconds: number | null): string {
function toggleOptions(): void { function toggleOptions(): void {
showOptions.value = !showOptions.value showOptions.value = !showOptions.value
// Extract formats when opening options if we haven't yet // 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() extractFormats()
} }
} }
@ -258,7 +285,7 @@ function formatTooltip(fmt: string): string {
class="url-field" class="url-field"
@paste="handlePaste" @paste="handlePaste"
@keydown.enter="submitDownload" @keydown.enter="submitDownload"
:disabled="isExtracting || store.isSubmitting" :disabled="isAnalyzing || store.isSubmitting"
/> />
<!-- Action row: gear, media toggle, download button --> <!-- Action row: gear, media toggle, download button -->
@ -304,9 +331,10 @@ function formatTooltip(fmt: string): string {
</button> </button>
</div> </div>
<div v-if="isLoadingInfo" class="url-preview loading"> <!-- Unified analyzing state -->
<div v-if="isAnalyzing" class="url-preview loading">
<span class="spinner"></span> <span class="spinner"></span>
Checking URL {{ analyzePhase }}
</div> </div>
<!-- URL preview: show what will be downloaded --> <!-- URL preview: show what will be downloaded -->
@ -350,11 +378,6 @@ function formatTooltip(fmt: string): string {
</div> </div>
</div> </div>
<div v-if="isExtracting" class="extract-loading">
<span class="spinner"></span>
Extracting available formats
</div>
<div v-if="extractError" class="extract-error"> <div v-if="extractError" class="extract-error">
{{ extractError }} {{ extractError }}
</div> </div>
@ -390,7 +413,7 @@ function formatTooltip(fmt: string): string {
:media-type="mediaType" :media-type="mediaType"
@select="onFormatSelect" @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. Paste a URL and formats will load automatically.
</div> </div>
</div> </div>
@ -505,15 +528,6 @@ button:disabled {
} }
/* Loading / errors */ /* 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 { .spinner {
display: inline-block; display: inline-block;
width: 16px; width: 16px;

View 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>

View file

@ -79,50 +79,3 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15); --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%; }
}