diff --git a/.gitignore b/.gitignore index dc094190..38cb9b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .kotlin .qodana build -src/main/resources/player/index.html +# Generated by `npm run build` in ui/ (vite outDir, emptyOutDir wipes it each build) +src/main/resources/player/ diff --git a/assets/CREDITS.md b/assets/CREDITS.md new file mode 100644 index 00000000..3fa00ce2 --- /dev/null +++ b/assets/CREDITS.md @@ -0,0 +1,18 @@ +# Sample Media Credits + +Audio fixtures used to exercise the player (codec inspector, tags, album art). + +## Kevin MacLeod Tracks + +Royalty-free music by **Kevin MacLeod** (https://incompetech.com), licensed +under **Creative Commons: By Attribution 4.0** (https://creativecommons.org/licenses/by/4.0/): + +| File | Track | +|------|-------| +| `sneaky-snitch.mp3` | "Sneaky Snitch" | +| `local-forecast-elevator.mp3` | "Local Forecast - Elevator" | +| `monkeys-spinning-monkeys.mp3` | "Monkeys Spinning Monkeys" | +| `fluffing-a-duck.mp3` | "Fluffing a Duck" | +| `pixel-peeker-polka-faster.mp3` | "Pixel Peeker Polka (faster)" | + +> Music by Kevin MacLeod — incompetech.com — Licensed under Creative Commons: By Attribution 4.0. diff --git a/assets/fluffing-a-duck.mp3 b/assets/fluffing-a-duck.mp3 new file mode 100644 index 00000000..51e77e25 Binary files /dev/null and b/assets/fluffing-a-duck.mp3 differ diff --git a/assets/local-forecast-elevator.mp3 b/assets/local-forecast-elevator.mp3 new file mode 100644 index 00000000..3eb634c5 Binary files /dev/null and b/assets/local-forecast-elevator.mp3 differ diff --git a/assets/monkeys-spinning-monkeys.mp3 b/assets/monkeys-spinning-monkeys.mp3 new file mode 100644 index 00000000..113e4330 Binary files /dev/null and b/assets/monkeys-spinning-monkeys.mp3 differ diff --git a/assets/pixel-peeker-polka-faster.mp3 b/assets/pixel-peeker-polka-faster.mp3 new file mode 100644 index 00000000..2a8ca89d Binary files /dev/null and b/assets/pixel-peeker-polka-faster.mp3 differ diff --git a/assets/sneaky-snitch.mp3 b/assets/sneaky-snitch.mp3 new file mode 100644 index 00000000..def5667b Binary files /dev/null and b/assets/sneaky-snitch.mp3 differ diff --git a/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt b/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt index 6f74bfa2..67ad2974 100644 --- a/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt +++ b/src/main/kotlin/dev/twango/jetplay/browser/PlayerBridge.kt @@ -4,6 +4,7 @@ import com.intellij.ide.BrowserUtil import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery +import dev.twango.jetplay.transcode.MediaInfo import javax.swing.SwingUtilities class PlayerBridge(private val browser: JBCefBrowser) { @@ -29,13 +30,38 @@ class PlayerBridge(private val browser: JBCefBrowser) { } } - fun updateProgress(percent: Double) = executeJs("window.jetplayUpdateProgress?.($percent)") + // Each of these stashes its value on window before calling the handler: the + // transcode/download can finish before the page has mounted and defined the + // handler (a fast transcode easily beats the page load), in which case the + // `?.` call is a silent no-op. The app reads the stash on mount so the state + // transition (esp. mediaReady → 'ready') is never dropped, leaving it stuck. + fun updateProgress(percent: Double) = + executeJs("window.__jetplayProgress=$percent;window.jetplayUpdateProgress?.($percent)") - fun updateDownloadProgress(percent: Double) = executeJs("window.jetplayUpdateDownloadProgress?.($percent)") + fun updateDownloadProgress(percent: Double) = + executeJs("window.__jetplayDownloadProgress=$percent;window.jetplayUpdateDownloadProgress?.($percent)") - fun mediaReady(url: String) = executeJs("window.jetplayReady?.('${escapeJs(url)}')") + fun mediaReady(url: String) = + executeJs("window.__jetplayReadyUrl='${escapeJs(url)}';window.jetplayReady?.('${escapeJs(url)}')") - fun showError(message: String) = executeJs("window.jetplayError?.('${escapeJs(message)}')") + fun showError(message: String) = + executeJs("window.__jetplayError='${escapeJs(message)}';window.jetplayError?.('${escapeJs(message)}')") + + // Stash the bars as well as calling the handler: extraction can finish + // before the page defines window.jetplayWaveform (short files), so the app + // reads window.__jetplayWaveform on mount to avoid dropping an early push. + fun sendWaveform(bars: List) = executeJs( + "window.__jetplayWaveform=[${bars.joinToString(",")}];" + + "if(window.jetplayWaveform)window.jetplayWaveform(window.__jetplayWaveform)", + ) + + // Same stash-then-call pattern as sendWaveform: the probe can finish before + // the page defines window.jetplayMediaInfo, so the app reads + // window.__jetplayMediaInfo on mount to avoid dropping an early push. + fun sendMediaInfo(info: MediaInfo) { + val json = mediaInfoJson(info) ?: return + executeJs("window.__jetplayMediaInfo=$json;if(window.jetplayMediaInfo)window.jetplayMediaInfo(window.__jetplayMediaInfo)") + } fun loadHtml(html: String) = browser.loadHTML(html) @@ -46,6 +72,9 @@ class PlayerBridge(private val browser: JBCefBrowser) { } companion object { + private const val HEX_RADIX = 16 + private const val UNICODE_ESCAPE_HEX_LENGTH = 4 + fun escapeJs(s: String): String = s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\"", "\\\"") @@ -53,5 +82,72 @@ class PlayerBridge(private val browser: JBCefBrowser) { .replace("\r", "") .replace("<", "\\x3c") .replace(">", "\\x3e") + + // The media-info payload carries arbitrary tag text and a base64 art URL, + // so it's built as strict JSON (a subset of JS) rather than spliced. + // Returns null when there's nothing to send. + internal fun mediaInfoJson(info: MediaInfo): String? { + val parts = buildList { + info.codec?.let { add("\"codec\":${jsonString(it)}") } + info.container?.let { add("\"container\":${jsonString(it)}") } + info.sampleRateHz?.let { add("\"sampleRateHz\":$it") } + info.channels?.let { add("\"channels\":$it") } + info.channelLabel?.let { add("\"channelLabel\":${jsonString(it)}") } + info.bitDepth?.let { add("\"bitDepth\":${jsonString(it)}") } + info.bitrateBps?.let { add("\"bitrateBps\":$it") } + info.durationMs?.let { add("\"durationMs\":$it") } + info.sizeBytes?.let { add("\"sizeBytes\":$it") } + info.width?.let { add("\"width\":$it") } + info.height?.let { add("\"height\":$it") } + info.frameRate?.let { add("\"frameRate\":$it") } + info.videoCodec?.let { add("\"videoCodec\":${jsonString(it)}") } + info.pixelFormat?.let { add("\"pixelFormat\":${jsonString(it)}") } + info.videoBitrateBps?.let { add("\"videoBitrateBps\":$it") } + if (info.tags.isNotEmpty()) { + val arr = info.tags.joinToString(",", "[", "]") { tag -> + "{\"label\":${jsonString(tag.label)},\"value\":${jsonString(tag.value)}}" + } + add("\"tags\":$arr") + } + info.albumArt?.let { add("\"albumArt\":${jsonString(it)}") } + } + if (parts.isEmpty()) return null + return parts.joinToString(",", "{", "}") + } + + internal fun jsonString(s: String): String { + val sb = StringBuilder(s.length + 2) + sb.append('"') + for (c in s) { + when (c) { + '"' -> sb.append("\\\"") + + '\\' -> sb.append("\\\\") + + '\n' -> sb.append("\\n") + + '\r' -> sb.append("\\r") + + '\t' -> sb.append("\\t") + + '\b' -> sb.append("\\b") + + '\u000C' -> sb.append("\\f") + + // Valid in JSON but terminate a JS string literal — must escape. + '\u2028' -> sb.append("\\u2028") + + '\u2029' -> sb.append("\\u2029") + + else -> if (c < ' ') { + sb.append("\\u").append(c.code.toString(HEX_RADIX).padStart(UNICODE_ESCAPE_HEX_LENGTH, '0')) + } else { + sb.append(c) + } + } + } + sb.append('"') + return sb.toString() + } } } diff --git a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt b/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt index 66542d75..71f59b50 100644 --- a/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt +++ b/src/main/kotlin/dev/twango/jetplay/editor/MediaFileEditor.kt @@ -4,6 +4,7 @@ import com.intellij.ide.BrowserUtil import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.fileEditor.FileEditorState import com.intellij.openapi.project.Project @@ -16,16 +17,26 @@ import dev.twango.jetplay.browser.PlayerBridge import dev.twango.jetplay.browser.PlayerConfig import dev.twango.jetplay.browser.PlayerHtmlLoader import dev.twango.jetplay.browser.UiStrings +import dev.twango.jetplay.media.MediaServer import dev.twango.jetplay.media.MediaSource import dev.twango.jetplay.media.RemoteFileMediaSource import dev.twango.jetplay.transcode.FfmpegAvailability +import dev.twango.jetplay.transcode.MediaInfoExtractor +import dev.twango.jetplay.transcode.MediaTranscoder import dev.twango.jetplay.transcode.TranscodeSession +import dev.twango.jetplay.transcode.WaveformExtractor import dev.twango.jetplay.transfer.DownloadSession import java.awt.BorderLayout import java.beans.PropertyChangeListener +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Future import javax.swing.JComponent import javax.swing.JPanel +// FileEditor mandates ~10 overrides; with the media-loading helpers this trips +// detekt's per-class function cap. Splitting them out would scatter tightly +// coupled editor logic for no readability gain. +@Suppress("TooManyFunctions") class MediaFileEditor(private val project: Project, private val file: VirtualFile, private val source: MediaSource) : UserDataHolderBase(), FileEditor { @@ -35,6 +46,13 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil private val htmlLoader = PlayerHtmlLoader(bridge) private var downloadSession: DownloadSession? = null private var transcodeSession: TranscodeSession? = null + private var waveformFuture: Future<*>? = null + private var mediaInfoFuture: Future<*>? = null + + // Loopback URLs handed out for this editor's media, released on dispose. + private val servedUrls = CopyOnWriteArrayList() + + private fun serve(file: java.io.File): String = MediaServer.serve(file).also { servedUrls.add(it) } private val uiStrings = UiStrings( downloadingLabel = JetPlayBundle.message("ui.downloading.label"), transcodingLabel = JetPlayBundle.message("ui.transcoding.label"), @@ -54,6 +72,44 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil } else { playDirectly() } + maybeSendWaveform() + maybeSendMediaInfo() + } + + /** + * Decode the waveform for local audio here with FFmpeg (off the EDT) and + * push the bars to the player — cheaper than the browser decoding the whole + * file, and works for any format. Remote sources need their download first + * (skipped for now); video has no waveform. + */ + private fun maybeSendWaveform() { + if (source.isVideo || source.isRemote || !FfmpegAvailability.available) return + // Raw telephony codecs need demuxer hints the extractor doesn't apply; + // skip them rather than risk a garbage waveform. + if (source.extension.lowercase() in MediaTranscoder.rawAudioExtensions) return + val localFile = source.toLocalFile() + waveformFuture = ApplicationManager.getApplication().executeOnPooledThread { + if (bridge.disposed) return@executeOnPooledThread + val bars = WaveformExtractor.extract(localFile) + if (bars.isNotEmpty()) bridge.sendWaveform(bars) + } + } + + /** + * Probe the file's container/codec/stream details with FFmpeg (off the EDT) + * and push them to the player's codec inspector — audio and video both. + * Local only (remote needs its download first); raw audio codecs lack the + * demuxer hints to probe cleanly, so they're skipped. + */ + private fun maybeSendMediaInfo() { + if (source.isRemote || !FfmpegAvailability.available) return + if (source.extension.lowercase() in MediaTranscoder.rawAudioExtensions) return + val localFile = source.toLocalFile() + mediaInfoFuture = ApplicationManager.getApplication().executeOnPooledThread { + if (bridge.disposed) return@executeOnPooledThread + val info = MediaInfoExtractor.extract(localFile) + if (info != null) bridge.sendMediaInfo(info) + } } private fun startDownload() { @@ -71,7 +127,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil if (source.needsTranscoding) { startTranscoding() } else { - bridge.mediaReady(source.resolvePlayableUrl()) + bridge.mediaReady(serve(source.toLocalFile())) } }.also { it.start() } } @@ -82,7 +138,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil return } if (source.isRemote) { - bridge.executeJs("window.jetplayStartTranscoding?.()") + bridge.executeJs("window.__jetplayState='loading';window.__jetplayProgress=0;window.jetplayStartTranscoding?.()") } else { htmlLoader.load( PlayerConfig( @@ -95,7 +151,9 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil ), ) } - transcodeSession = TranscodeSession(source.toLocalFile(), bridge).also { it.start() } + transcodeSession = TranscodeSession(source.toLocalFile(), bridge) { transcoded -> + bridge.mediaReady(serve(transcoded)) + }.also { it.start() } } private fun playDirectly() { @@ -104,7 +162,7 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil isVideo = source.isVideo, fileName = source.fileName, fileExtension = source.extension, - mediaUrl = source.resolvePlayableUrl(), + mediaUrl = serve(source.toLocalFile()), ui = uiStrings, ), ) @@ -140,6 +198,9 @@ class MediaFileEditor(private val project: Project, private val file: VirtualFil override fun dispose() { downloadSession?.cancel() transcodeSession?.cancel() + waveformFuture?.cancel(true) + mediaInfoFuture?.cancel(true) + servedUrls.forEach(MediaServer::release) bridge.dispose() } } diff --git a/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt b/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt index 8d754831..ca63095c 100644 --- a/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt +++ b/src/main/kotlin/dev/twango/jetplay/media/LocalFileMediaSource.kt @@ -16,7 +16,5 @@ class LocalFileMediaSource(private val file: VirtualFile) : MediaSource { override val isRemote: Boolean = false - override fun resolvePlayableUrl(): String = file.toNioPath().toUri().toString() - override fun toLocalFile(): File = file.toNioPath().toFile() } diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt b/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt new file mode 100644 index 00000000..5d175d2d --- /dev/null +++ b/src/main/kotlin/dev/twango/jetplay/media/MediaServer.kt @@ -0,0 +1,181 @@ +package dev.twango.jetplay.media + +import com.intellij.openapi.diagnostic.Logger +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.io.File +import java.io.RandomAccessFile +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * Tiny loopback HTTP server that streams registered local media files to the + * JCEF browser. Serving over http with CORS + range — rather than file:// — + * lets the page fetch()/decode the audio (the scrubber's scratch buffer, + * in-browser waveform decode) and range-seek large files, none of which a + * null-origin file:// page can do. + * + * Security: binds 127.0.0.1 only; serves ONLY files registered via [serve], + * each under an unguessable random token (no directory listing, no traversal). + */ +object MediaServer { + + private val log = Logger.getInstance(MediaServer::class.java) + private val files = ConcurrentHashMap() + private const val CHUNK = 64 * 1024 + + // HTTP status codes, named to document intent and satisfy detekt's MagicNumber. + private const val HTTP_OK = 200 + private const val HTTP_NO_CONTENT = 204 + private const val HTTP_PARTIAL_CONTENT = 206 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_NOT_FOUND = 404 + private const val HTTP_RANGE_NOT_SATISFIABLE = 416 + + @Volatile + private var server: HttpServer? = null + + /** Registers [file] and returns a loopback URL the browser can fetch + play. */ + @Synchronized + fun serve(file: File): String { + val srv = server ?: start().also { server = it } + val token = UUID.randomUUID().toString().replace("-", "") + files[token] = file + return "http://127.0.0.1:${srv.address.port}/$token" + } + + /** Stops serving the file behind [url]. */ + fun release(url: String) { + files.remove(url.substringAfterLast('/')) + } + + private fun start(): HttpServer { + val srv = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + srv.executor = Executors.newCachedThreadPool { r -> + Thread(r, "jetplay-media-server").apply { isDaemon = true } + } + srv.createContext("/", ::handle) + srv.start() + log.info("Media server listening on 127.0.0.1:${srv.address.port}") + return srv + } + + private fun handle(exchange: HttpExchange) { + try { + val headers = exchange.responseHeaders + // The JCEF player page is a null-origin loadHTML document, so it has + // no stable origin to allowlist — ACAO:* is effectively required for + // it to fetch()/decode the media. The real boundary is the 122-bit + // token plus the loopback bind, Host check, and per-editor release. + headers.add("Access-Control-Allow-Origin", "*") + + if (exchange.requestMethod == "OPTIONS") { + headers.add("Access-Control-Allow-Methods", "GET, OPTIONS") + headers.add("Access-Control-Allow-Headers", "Range") + // Only the preflight needs Chrome's Private Network Access opt-in. + headers.add("Access-Control-Allow-Private-Network", "true") + exchange.sendResponseHeaders(HTTP_NO_CONTENT, -1) + return + } + + // Reject non-loopback Host headers to neutralize DNS rebinding. + if (!isLoopbackHost(exchange.requestHeaders.getFirst("Host"))) { + exchange.sendResponseHeaders(HTTP_FORBIDDEN, -1) + return + } + + val file = files[exchange.requestURI.path.trimStart('/')] + if (file == null || !file.isFile) { + exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) + return + } + + headers.add("Content-Type", contentType(file)) + headers.add("Accept-Ranges", "bytes") + val length = file.length() + // Only a single-range "bytes=" request gets partial content; multi-range, + // garbage, and empty files fall back to a full 200. + val rangeHeader = exchange.requestHeaders.getFirst("Range") + ?.takeIf { it.startsWith("bytes=") && !it.contains(',') } + if (rangeHeader != null && length > 0) { + writeRange(exchange, file, length, rangeHeader) + } else { + exchange.sendResponseHeaders(HTTP_OK, if (length == 0L) -1 else length) + file.inputStream().use { it.copyTo(exchange.responseBody) } + } + } catch (e: Exception) { + log.warn("Media server request failed", e) + } finally { + exchange.close() + } + } + + private fun isLoopbackHost(host: String?): Boolean { + if (host.isNullOrBlank()) return false + val name = if (host.startsWith("[")) { + host.substringAfter("[").substringBefore("]") // [::1]:port + } else { + host.substringBefore(":") // 127.0.0.1:port / localhost:port + } + return name.equals("127.0.0.1", true) || name.equals("localhost", true) || name.equals("::1", true) + } + + private fun writeRange(exchange: HttpExchange, file: File, length: Long, range: String) { + val spec = range.removePrefix("bytes=").split('-', limit = 2) + val startTok = spec.getOrNull(0)?.trim().orEmpty() + val endTok = spec.getOrNull(1)?.trim().orEmpty() + + val start: Long + val end: Long + if (startTok.isEmpty()) { + // Suffix range "bytes=-N": the LAST n bytes. + val n = endTok.toLongOrNull() + if (n == null || n <= 0) return send416(exchange, length) + start = maxOf(0, length - n) + end = length - 1 + } else { + val s = startTok.toLongOrNull() + if (s == null || s >= length) return send416(exchange, length) + start = s + end = (endTok.toLongOrNull() ?: (length - 1)).coerceIn(start, length - 1) + } + + val count = end - start + 1 + exchange.responseHeaders.add("Content-Range", "bytes $start-$end/$length") + exchange.sendResponseHeaders(HTTP_PARTIAL_CONTENT, count) + RandomAccessFile(file, "r").use { raf -> + raf.seek(start) + val buffer = ByteArray(CHUNK) + var remaining = count + while (remaining > 0) { + val read = raf.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) + if (read == -1) break + exchange.responseBody.write(buffer, 0, read) + remaining -= read + } + } + } + + private fun send416(exchange: HttpExchange, length: Long) { + exchange.responseHeaders.add("Content-Range", "bytes */$length") + exchange.sendResponseHeaders(HTTP_RANGE_NOT_SATISFIABLE, -1) + } + + private fun contentType(file: File): String = runCatching { Files.probeContentType(file.toPath()) }.getOrNull() + ?: when (file.extension.lowercase()) { + "mp3" -> "audio/mpeg" + "ogg", "oga" -> "audio/ogg" + "opus" -> "audio/opus" + "wav" -> "audio/wav" + "flac" -> "audio/flac" + "m4a", "aac" -> "audio/mp4" + "webm" -> "video/webm" + "mp4", "m4v" -> "video/mp4" + "ogv" -> "video/ogg" + else -> "application/octet-stream" + } +} diff --git a/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt b/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt index c0165b54..4e10ce95 100644 --- a/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt +++ b/src/main/kotlin/dev/twango/jetplay/media/MediaSource.kt @@ -8,6 +8,5 @@ interface MediaSource { val isVideo: Boolean val needsTranscoding: Boolean val isRemote: Boolean - fun resolvePlayableUrl(): String fun toLocalFile(): File } diff --git a/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt b/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt index 37e573f9..2f88d63d 100644 --- a/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt +++ b/src/main/kotlin/dev/twango/jetplay/media/RemoteFileMediaSource.kt @@ -28,8 +28,5 @@ class RemoteFileMediaSource(private val file: VirtualFile) : MediaSource { localFile = file } - override fun resolvePlayableUrl(): String = localFile?.toURI()?.toString() - ?: error("Remote file not yet downloaded") - override fun toLocalFile(): File = localFile ?: error("Remote file not yet downloaded") } diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt b/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt new file mode 100644 index 00000000..a6622d92 --- /dev/null +++ b/src/main/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractor.kt @@ -0,0 +1,261 @@ +package dev.twango.jetplay.transcode + +import com.intellij.openapi.diagnostic.Logger +import org.bytedeco.ffmpeg.avformat.AVStream +import org.bytedeco.ffmpeg.global.avcodec +import org.bytedeco.ffmpeg.global.avformat +import org.bytedeco.ffmpeg.global.avutil +import org.bytedeco.javacv.FFmpegFrameGrabber +import org.bytedeco.javacv.FrameGrabber +import java.io.File +import java.util.Base64 + +/** + * Technical metadata for the "codec inspector" expandable header in the player. + * All fields are nullable so the UI can simply skip anything FFmpeg couldn't + * determine rather than render a wrong or placeholder value. + */ +data class MediaInfo( + val codec: String?, + val container: String?, + val sampleRateHz: Int?, + val channels: Int?, + val channelLabel: String?, + /** Only set when meaningful (PCM / lossless). Null for lossy codecs. */ + val bitDepth: String?, + val bitrateBps: Long?, + val durationMs: Long?, + val sizeBytes: Long?, + // Video-stream fields (null for audio-only files). + val width: Int? = null, + val height: Int? = null, + val frameRate: Double? = null, + val videoCodec: String? = null, + val pixelFormat: String? = null, + val videoBitrateBps: Long? = null, + /** Embedded text tags (title/artist/album/…), in display order. */ + val tags: List = emptyList(), + /** Embedded cover art as a `data:` URL, or null when there is none. */ + val albumArt: String? = null, +) + +/** One embedded metadata tag, already labeled for display. */ +data class MediaTag(val label: String, val value: String) + +/** + * Probes a media file's container/codec/stream details with the bundled FFmpeg + * (both audio and video streams). + * + * Only reads the header — no frame decoding — so it's cheap. Returns null when + * the file has no readable audio or video stream, mirroring the graceful-empty + * contract of [WaveformExtractor]; the UI then just shows the filename as before. + */ +object MediaInfoExtractor { + + private val log = Logger.getInstance(MediaInfoExtractor::class.java) + + // Codecs where a source bit depth is a real, honest property to surface. + // Lossy codecs decode to float internally, so their "bit depth" would be + // misleading — we omit it for those. + private val LOSSLESS = setOf("flac", "alac", "wavpack", "truehd", "mlp", "tta", "als") + + // Cover art over this is skipped — it would only bloat the bridge payload, + // and a blurred background needs no fidelity. Typical embedded art is < 1 MB. + private const val MAX_ART_BYTES = 4_000_000 + + // Embedded tags to surface, in display order, mapped from FFmpeg's + // normalized (lowercase) metadata keys to a human label. + private val TAG_FIELDS = listOf( + "title" to "Title", + "artist" to "Artist", + "album" to "Album", + "album_artist" to "Album artist", + "composer" to "Composer", + "track" to "Track", + "disc" to "Disc", + "date" to "Date", + "genre" to "Genre", + "publisher" to "Publisher", + "comment" to "Comment", + "rating" to "Rating", + ) + + private const val BITS_PER_BYTE = 8 + private const val MILLIS_PER_SECOND = 1000 + private const val CHANNELS_5_1 = 6 + private const val CHANNELS_7_1 = 8 + + // Image-signature sniffing for embedded cover art. + private const val IMAGE_SNIFF_MIN_BYTES = 12 + private const val WEBP_BRAND_OFFSET = 8 + private val SIG_JPEG = intArrayOf(0xFF, 0xD8, 0xFF) + private val SIG_PNG = intArrayOf(0x89, 0x50, 0x4E, 0x47) + private val SIG_GIF = intArrayOf(0x47, 0x49, 0x46) + private val SIG_RIFF = intArrayOf(0x52, 0x49, 0x46, 0x46) + private val SIG_WEBP = intArrayOf(0x57, 0x45, 0x42, 0x50) + + /** Returns the file's stream metadata, or null if it has no readable streams. */ + fun extract(file: File): MediaInfo? { + // RAW modes so the *source* sample/pixel formats are reported; the SHORT/ + // COLOR defaults would report the decoder's output format instead. + val grabber = FFmpegFrameGrabber(file).apply { + sampleMode = FrameGrabber.SampleMode.RAW + imageMode = FrameGrabber.ImageMode.RAW + } + return try { + grabber.start() + val channels = grabber.audioChannels + val width = grabber.imageWidth + val height = grabber.imageHeight + val hasAudio = channels > 0 + val hasVideo = width > 0 && height > 0 + if (!hasAudio && !hasVideo) return null + + val durationMs = grabber.lengthInTime.takeIf { it > 0 }?.div(1000) + val sizeBytes = file.length().takeIf { it > 0 } + + // Audio (canonical codec from the id, not the decoder name). + val audioCodec = if (hasAudio) canonicalCodec(grabber.audioCodec) else null + val audioBitrate = if (hasAudio) grabber.audioBitrate.toLong().takeIf { it > 0 } else null + // The size/duration fallback is the whole-file bitrate, so it only + // stands in for the audio bitrate when there is no video stream. + val bitrate = audioBitrate ?: if (!hasVideo) computeBitrate(sizeBytes, durationMs) else null + + // Video. + val videoCodec = if (hasVideo) canonicalCodec(grabber.videoCodec) else null + val frameRate = if (hasVideo) grabber.videoFrameRate.takeIf { it.isFinite() && it > 0 } else null + val pixelFormat = if (hasVideo) { + avutil.av_get_pix_fmt_name(grabber.pixelFormat)?.getString()?.takeIf { it.isNotBlank() } + } else { + null + } + val videoBitrate = if (hasVideo) grabber.videoBitrate.toLong().takeIf { it > 0 } else null + + MediaInfo( + codec = audioCodec, + container = grabber.format?.substringBefore(",")?.takeIf { it.isNotBlank() }, + sampleRateHz = if (hasAudio) grabber.sampleRate.takeIf { it > 0 } else null, + channels = if (hasAudio) channels else null, + channelLabel = if (hasAudio) channelLabel(channels) else null, + bitDepth = if (hasAudio) bitDepth(audioCodec, grabber.sampleFormat) else null, + bitrateBps = bitrate, + durationMs = durationMs, + sizeBytes = sizeBytes, + width = width.takeIf { hasVideo }, + height = height.takeIf { hasVideo }, + frameRate = frameRate, + videoCodec = videoCodec, + pixelFormat = pixelFormat, + videoBitrateBps = videoBitrate, + tags = buildTags(grabber.metadata ?: emptyMap()), + albumArt = extractAlbumArt(grabber), + ) + } catch (e: Exception) { + log.warn("Media info extraction failed for ${file.name}", e) + null + } finally { + safely("grabber.stop") { grabber.stop() } + safely("grabber.release") { grabber.release() } + } + } + + // Canonical codec name from the codec id (e.g. "mp3", "h264"), not the + // decoder name getAudioCodecName()/getVideoCodecName() returns ("mp3float"). + private fun canonicalCodec(codecId: Int): String? = + avcodec.avcodec_get_name(codecId)?.getString()?.takeIf { it.isNotBlank() && it != "unknown" && it != "none" } + + private fun computeBitrate(sizeBytes: Long?, durationMs: Long?): Long? { + if (sizeBytes == null || durationMs == null || durationMs <= 0) return null + return sizeBytes * BITS_PER_BYTE * MILLIS_PER_SECOND / durationMs + } + + private fun channelLabel(channels: Int): String? = when { + channels <= 0 -> null + channels == 1 -> "mono" + channels == 2 -> "stereo" + channels == CHANNELS_5_1 -> "5.1" + channels == CHANNELS_7_1 -> "7.1" + else -> "$channels ch" + } + + private val PCM_PATTERN = Regex("""^pcm_([fsu])(\d+)""") + + private fun bitDepth(codec: String?, sampleFormat: Int): String? = when { + codec == null -> null + + // PCM encodes its depth in the codec name (e.g. pcm_s24le → 24-bit). + // More accurate than the sample format, which widens pcm_s24le to S32. + codec.startsWith("pcm_") -> PCM_PATTERN.find(codec)?.let { match -> + val bits = match.groupValues[2] + if (match.groupValues[1] == "f") "$bits-bit float" else "$bits-bit" + } + + codec in LOSSLESS -> when (sampleFormat) { + avutil.AV_SAMPLE_FMT_U8, avutil.AV_SAMPLE_FMT_U8P -> "8-bit" + avutil.AV_SAMPLE_FMT_S16, avutil.AV_SAMPLE_FMT_S16P -> "16-bit" + avutil.AV_SAMPLE_FMT_S32, avutil.AV_SAMPLE_FMT_S32P -> "32-bit" + avutil.AV_SAMPLE_FMT_FLT, avutil.AV_SAMPLE_FMT_FLTP -> "32-bit float" + avutil.AV_SAMPLE_FMT_DBL, avutil.AV_SAMPLE_FMT_DBLP -> "64-bit float" + else -> null + } + + else -> null + } + + /** Maps FFmpeg's metadata map to an ordered, labeled list of display tags. */ + internal fun buildTags(metadata: Map): List { + if (metadata.isEmpty()) return emptyList() + // FFmpeg keys are normally lowercase, but be tolerant of odd containers. + val lower = metadata.entries.associate { it.key.lowercase() to it.value } + return TAG_FIELDS.mapNotNull { (key, label) -> + lower[key]?.trim()?.takeIf { it.isNotEmpty() }?.let { MediaTag(label, it) } + } + } + + /** + * Reads the first attached-picture stream's raw image bytes (the packet data + * IS the encoded cover) and returns it as a `data:` URL for the UI to blur + * behind the player. No decode/re-encode — just sniff the type and base64. + */ + private fun extractAlbumArt(grabber: FFmpegFrameGrabber): String? { + return try { + val oc = grabber.formatContext ?: return null + (0 until oc.nb_streams()).firstNotNullOfOrNull { albumArtFromStream(oc.streams(it)) } + } catch (e: Exception) { + log.warn("Album art extraction failed", e) + null + } + } + + /** Returns the cover-art `data:` URL from an attached-picture stream, or null. */ + private fun albumArtFromStream(stream: AVStream): String? { + if (stream.disposition() and avformat.AV_DISPOSITION_ATTACHED_PIC == 0) return null + val pkt = stream.attached_pic() + val size = pkt.size() + val data = pkt.data() + if (size <= 0 || size > MAX_ART_BYTES || data == null) return null + val bytes = ByteArray(size) + data.capacity(size.toLong()).get(bytes) + return sniffImageMime(bytes)?.let { "data:$it;base64,${Base64.getEncoder().encodeToString(bytes)}" } + } + + private fun sniffImageMime(b: ByteArray): String? { + if (b.size < IMAGE_SNIFF_MIN_BYTES) return null + fun at(offset: Int, sig: IntArray) = sig.withIndex().all { (k, v) -> b[offset + k] == v.toByte() } + return when { + at(0, SIG_JPEG) -> "image/jpeg" + at(0, SIG_PNG) -> "image/png" + at(0, SIG_GIF) -> "image/gif" + at(0, SIG_RIFF) && at(WEBP_BRAND_OFFSET, SIG_WEBP) -> "image/webp" + else -> null + } + } + + private inline fun safely(action: String, block: () -> Unit) { + try { + block() + } catch (e: Exception) { + log.warn("$action failed", e) + } + } +} diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt b/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt index 1472c500..71fc4bcf 100644 --- a/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt +++ b/src/main/kotlin/dev/twango/jetplay/transcode/MediaTranscoder.kt @@ -15,6 +15,7 @@ object MediaTranscoder { private const val DEFAULT_VIDEO_BITRATE = 2_000_000 private const val DEFAULT_FRAME_RATE = 30.0 private const val DEFAULT_GOP_SIZE = 120 + private val VP9_THREADS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8) private const val OPUS_BITRATE = 128_000 private const val OPUS_SAMPLE_RATE = 48_000 private const val PROGRESS_COMPLETE = 100.0 @@ -111,6 +112,13 @@ object MediaTranscoder { recorder.videoBitrate = grabber.videoBitrate.takeIf { it > 0 } ?: DEFAULT_VIDEO_BITRATE recorder.frameRate = grabber.frameRate.takeIf { it > 0 } ?: DEFAULT_FRAME_RATE recorder.gopSize = DEFAULT_GOP_SIZE + // libvpx VP9 defaults to a very slow deadline (multiples of real time), + // which makes converting a normal HD clip look like it's hung. We only + // need a watchable preview, so encode in real time across all cores. + recorder.setVideoOption("deadline", "realtime") + recorder.setVideoOption("cpu-used", "8") + recorder.setVideoOption("row-mt", "1") + recorder.setVideoOption("threads", VP9_THREADS.toString()) } if (hasAudio) { recorder.audioCodec = avcodec.AV_CODEC_ID_OPUS diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt b/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt index 346033d4..f85faa30 100644 --- a/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt +++ b/src/main/kotlin/dev/twango/jetplay/transcode/TranscodeSession.kt @@ -6,7 +6,11 @@ import dev.twango.jetplay.browser.PlayerBridge import java.io.File import kotlin.concurrent.thread -class TranscodeSession(private val inputFile: File, private val bridge: PlayerBridge) { +class TranscodeSession( + private val inputFile: File, + private val bridge: PlayerBridge, + private val onReady: (File) -> Unit, +) { companion object { private val log = Logger.getInstance(TranscodeSession::class.java) @@ -18,14 +22,20 @@ class TranscodeSession(private val inputFile: File, private val bridge: PlayerBr private var thread: Thread? = null + // Guards the cancelled-then-onReady handoff so cancel() can't slip its + // release pass between the check and the callback (which re-serves media). + private val readyLock = Any() + fun start() { thread = thread(name = "jetplay-transcode", isDaemon = true) { try { val transcoded = MediaTranscoder.transcode(inputFile) { percent -> if (!cancelled) bridge.updateProgress(percent) } - if (!cancelled) { - bridge.mediaReady(transcoded.toURI().toString()) + // Re-check under the lock so a racing cancel() either wins (we + // skip onReady) or completes only after onReady has re-served. + synchronized(readyLock) { + if (!cancelled) onReady(transcoded) } } catch (_: InterruptedException) { log.info("Transcoding interrupted for ${inputFile.name}") @@ -39,7 +49,7 @@ class TranscodeSession(private val inputFile: File, private val bridge: PlayerBr } fun cancel() { - cancelled = true + synchronized(readyLock) { cancelled = true } thread?.interrupt() } } diff --git a/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt b/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt new file mode 100644 index 00000000..435d9cc7 --- /dev/null +++ b/src/main/kotlin/dev/twango/jetplay/transcode/WaveformExtractor.kt @@ -0,0 +1,97 @@ +package dev.twango.jetplay.transcode + +import com.intellij.openapi.diagnostic.Logger +import org.bytedeco.javacv.FFmpegFrameGrabber +import org.bytedeco.javacv.FrameGrabber +import java.io.File +import java.nio.ShortBuffer +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Decodes an audio file into normalized amplitude bars for the UI waveform. + * + * Computed here with the bundled FFmpeg and pushed to the page — cheaper than + * the browser fetching and decoding the whole file. The output matches the + * shape the UI's `sampleWaveform` produces: amplitudes in `[0, 1]` at a fixed + * bars-per-second. + */ +object WaveformExtractor { + + private val log = Logger.getInstance(WaveformExtractor::class.java) + + private const val DEFAULT_BARS_PER_SECOND = 8 + private const val MAX_DURATION_SECONDS = 30 * 60 + private const val GAIN = 3.0 + private const val SHORT_FULL_SCALE = 32768.0 + private const val QUANTIZE = 100.0 + + /** + * Returns one normalized amplitude per `1/[barsPerSecond]` of audio, or an + * empty list if [file] has no readable audio or exceeds the duration cap. + */ + fun extract(file: File, barsPerSecond: Int = DEFAULT_BARS_PER_SECOND): List { + val grabber = FFmpegFrameGrabber(file).apply { + audioChannels = 1 // request a mono downmix + sampleMode = FrameGrabber.SampleMode.SHORT + } + return try { + grabber.start() + // No explicit no-audio guard: grabSamples() yields nothing for a + // file without an audio stream, so sampleToBars returns empty. + // Fast path: skip when the container reports a length over the cap. + // (lengthInTime is 0 / AV_NOPTS_VALUE for some inputs, so this is a + // best-effort skip; sampleToBars enforces the real ceiling.) + val durationSeconds = grabber.lengthInTime / 1_000_000.0 + if (durationSeconds > MAX_DURATION_SECONDS) { + log.info("Skipping waveform for ${file.name}: ${durationSeconds.roundToInt()}s exceeds cap") + return emptyList() + } + sampleToBars(grabber, barsPerSecond) + } catch (e: Exception) { + log.warn("Waveform extraction failed for ${file.name}", e) + emptyList() + } finally { + safely("grabber.stop") { grabber.stop() } + safely("grabber.release") { grabber.release() } + } + } + + private fun sampleToBars(grabber: FFmpegFrameGrabber, barsPerSecond: Int): List { + val samplesPerBar = (grabber.sampleRate / barsPerSecond).coerceAtLeast(1) + // Hard ceiling on output: bounds the decode even when the container + // duration is unknown/misreported, and lets dispose() interrupt us. + val maxBars = MAX_DURATION_SECONDS * barsPerSecond + val bars = ArrayList(minOf(maxBars, 4096)) + var sum = 0.0 + var count = 0 + while (bars.size < maxBars && !Thread.currentThread().isInterrupted) { + val frame = grabber.grabSamples() ?: break + val buffer = frame.samples?.firstOrNull() as? ShortBuffer + if (buffer != null) { + while (buffer.hasRemaining() && bars.size < maxBars) { + sum += abs(buffer.get().toInt()) / SHORT_FULL_SCALE + count++ + if (count == samplesPerBar) { + bars.add(normalize(sum / count)) + sum = 0.0 + count = 0 + } + } + } + } + if (count > 0 && bars.size < maxBars) bars.add(normalize(sum / count)) + return bars + } + + private fun normalize(average: Double): Double = (min(1.0, average * GAIN) * QUANTIZE).roundToInt() / QUANTIZE + + private inline fun safely(action: String, block: () -> Unit) { + try { + block() + } catch (e: Exception) { + log.warn("$action failed", e) + } + } +} diff --git a/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt b/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt index 2a8dcf16..17bb6131 100644 --- a/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt +++ b/src/test/kotlin/dev/twango/jetplay/browser/PlayerBridgeEscapeTest.kt @@ -1,6 +1,11 @@ package dev.twango.jetplay.browser +import dev.twango.jetplay.transcode.MediaInfo +import dev.twango.jetplay.transcode.MediaTag import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class PlayerBridgeEscapeTest { @@ -51,4 +56,48 @@ class PlayerBridgeEscapeTest { fun emptyString() { assertEquals("", PlayerBridge.escapeJs("")) } + + // --- media-info JSON serialization (carries arbitrary tag text) --- + + @Test + fun jsonStringEscapesQuotesBackslashControlAndLineSeparators() { + assertEquals("\"a\\\"b\"", PlayerBridge.jsonString("a\"b")) + assertEquals("\"a\\\\b\"", PlayerBridge.jsonString("a\\b")) + assertEquals("\"l1\\nl2\"", PlayerBridge.jsonString("l1\nl2")) + // A lone control char becomes a \u00xx escape. + assertEquals("\"a\\u0001b\"", PlayerBridge.jsonString("a\u0001b")) + // U+2028 is legal JSON but terminates a JS string literal, so it must escape. + assertEquals("\"a\\u2028b\"", PlayerBridge.jsonString("a\u2028b")) + } + + @Test + fun mediaInfoJsonEmbedsTagsAndEscapesArbitraryText() { + val info = MediaInfo( + codec = "mp3", + container = "mp3", + sampleRateHz = 44100, + channels = 2, + channelLabel = "stereo", + bitDepth = null, + bitrateBps = 320000, + durationMs = 1000, + sizeBytes = 40000, + tags = listOf(MediaTag("Title", "O'Brien \"x\""), MediaTag("Artist", "A\\B")), + albumArt = "data:image/png;base64,AAAA", + ) + val json = PlayerBridge.mediaInfoJson(info)!! + assertTrue(json.startsWith("{") && json.endsWith("}")) + assertTrue(json.contains("\"sampleRateHz\":44100")) + assertTrue(json.contains("\"label\":\"Title\",\"value\":\"O'Brien \\\"x\\\"\"")) + assertTrue(json.contains("\"value\":\"A\\\\B\"")) + assertTrue(json.contains("\"albumArt\":\"data:image/png;base64,AAAA\"")) + // Null fields are omitted entirely. + assertFalse(json.contains("bitDepth")) + } + + @Test + fun mediaInfoJsonReturnsNullWhenEverythingIsEmpty() { + val empty = MediaInfo(null, null, null, null, null, null, null, null, null) + assertNull(PlayerBridge.mediaInfoJson(empty)) + } } diff --git a/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt b/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt new file mode 100644 index 00000000..db9d350b --- /dev/null +++ b/src/test/kotlin/dev/twango/jetplay/media/MediaServerTest.kt @@ -0,0 +1,112 @@ +package dev.twango.jetplay.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.net.HttpURLConnection +import java.net.Socket +import java.net.URI +import java.nio.file.Files + +class MediaServerTest { + + @Test + fun servesRegisteredFileWithCors() { + withTempFile("hello world".toByteArray(), ".mp3") { file -> + val conn = open(MediaServer.serve(file)) + assertEquals(200, conn.responseCode) + assertEquals("*", conn.getHeaderField("Access-Control-Allow-Origin")) + assertEquals("bytes", conn.getHeaderField("Accept-Ranges")) + assertEquals("hello world", conn.inputStream.readBytes().decodeToString()) + conn.disconnect() + } + } + + @Test + fun servesRangeRequestsAsPartialContent() { + withTempFile("0123456789".toByteArray(), ".bin") { file -> + val conn = open(MediaServer.serve(file)) + conn.setRequestProperty("Range", "bytes=2-5") + assertEquals(206, conn.responseCode) + assertEquals("bytes 2-5/10", conn.getHeaderField("Content-Range")) + assertEquals("2345", conn.inputStream.readBytes().decodeToString()) + conn.disconnect() + } + } + + @Test + fun servesSuffixRangeAsTailBytes() { + withTempFile("0123456789".toByteArray(), ".bin") { file -> + val conn = open(MediaServer.serve(file)) + conn.setRequestProperty("Range", "bytes=-3") + assertEquals(206, conn.responseCode) + assertEquals("bytes 7-9/10", conn.getHeaderField("Content-Range")) + assertEquals("789", conn.inputStream.readBytes().decodeToString()) + conn.disconnect() + } + } + + @Test + fun rangePastEndReturns416() { + withTempFile("0123456789".toByteArray(), ".bin") { file -> + val conn = open(MediaServer.serve(file)) + conn.setRequestProperty("Range", "bytes=100-200") + assertEquals(416, conn.responseCode) + conn.disconnect() + } + } + + @Test + fun rejectsNonLoopbackHost() { + withTempFile("x".toByteArray(), ".mp3") { file -> + val uri = URI(MediaServer.serve(file)) + Socket(uri.host, uri.port).use { socket -> + socket.getOutputStream().apply { + write("GET ${uri.path} HTTP/1.1\r\nHost: evil.attacker.com\r\nConnection: close\r\n\r\n".toByteArray()) + flush() + } + val statusLine = socket.getInputStream().bufferedReader().readLine() ?: "" + assertTrue("expected 403, got: $statusLine", statusLine.contains("403")) + } + } + } + + @Test + fun unknownTokenReturns404() { + withTempFile("x".toByteArray(), ".mp3") { file -> + val base = MediaServer.serve(file).substringBeforeLast('/') + val conn = open("$base/deadbeefdeadbeef") + assertEquals(404, conn.responseCode) + conn.disconnect() + } + } + + @Test + fun releasedTokenStopsBeingServed() { + withTempFile("bye".toByteArray(), ".mp3") { file -> + val url = MediaServer.serve(file) + assertEquals(200, open(url).run { responseCode.also { disconnect() } }) + MediaServer.release(url) + assertEquals(404, open(url).run { responseCode.also { disconnect() } }) + } + } + + @Test + fun servesOnLoopbackOnly() { + withTempFile("x".toByteArray(), ".mp3") { file -> + assertTrue(MediaServer.serve(file).startsWith("http://127.0.0.1:")) + } + } + + private fun open(url: String): HttpURLConnection = (URI(url).toURL().openConnection() as HttpURLConnection) + + private fun withTempFile(bytes: ByteArray, suffix: String, block: (File) -> Unit) { + val file = Files.createTempFile("jetplay-server-test", suffix).toFile().apply { writeBytes(bytes) } + try { + block(file) + } finally { + file.delete() + } + } +} diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt b/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt new file mode 100644 index 00000000..6336eb36 --- /dev/null +++ b/src/test/kotlin/dev/twango/jetplay/transcode/MediaInfoExtractorTest.kt @@ -0,0 +1,120 @@ +package dev.twango.jetplay.transcode + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import kotlin.math.PI +import kotlin.math.sin + +class MediaInfoExtractorTest { + + @Test + fun extractsTechnicalMetadataFromPcmWav() { + Assume.assumeTrue("FFmpeg native libraries required", FfmpegAvailability.available) + val wav = generateSineWav(durationSec = 2.0, sampleRate = 8000, freq = 440.0) + try { + val info = MediaInfoExtractor.extract(wav) + assertNotNull("expected metadata for a valid WAV", info) + info!! + + assertEquals("pcm_s16le", info.codec) + assertEquals("wav", info.container) + assertEquals(8000, info.sampleRateHz) + assertEquals(1, info.channels) + assertEquals("mono", info.channelLabel) + // Bit depth comes from the PCM codec name, not the (widened) sample format. + assertEquals("16-bit", info.bitDepth) + + val durationMs = info.durationMs + assertNotNull("duration should be probed", durationMs) + assertTrue("a 2s clip should report ~2000ms", durationMs!! in 1900..2100) + + val sizeBytes = info.sizeBytes + assertNotNull("size must be the real file length", sizeBytes) + assertTrue(sizeBytes!! > 0L) + + assertNotNull("PCM WAV has a derivable bitrate", info.bitrateBps) + assertTrue("a bare generated WAV carries no tags", info.tags.isEmpty()) + assertNull("a bare generated WAV has no cover art", info.albumArt) + } finally { + wav.delete() + } + } + + @Test + fun buildTagsOrdersLabelsAndSkipsBlanksAndUnknowns() { + val tags = MediaInfoExtractor.buildTags( + mapOf( + "ARTIST" to "Daft Punk", // upper-case key still matches + "title" to "Aerodynamic", + "album" to "Discovery", + "date" to "2001", + "genre" to " ", // blank -> skipped + "unknown_key" to "x", // not a surfaced field -> skipped + ), + ) + assertEquals(listOf("Title", "Artist", "Album", "Date"), tags.map { it.label }) + assertEquals("Aerodynamic", tags.first().value) + } + + @Test + fun returnsNullForNonAudioInput() { + Assume.assumeTrue("FFmpeg native libraries required", FfmpegAvailability.available) + val notAudio = Files.createTempFile("jetplay-not-audio", ".bin").toFile() + notAudio.writeBytes(ByteArray(2048) { 0x7f }) + try { + assertNull(MediaInfoExtractor.extract(notAudio)) + } finally { + notAudio.delete() + } + } + + @Test + fun extractsVideoMetadataFromWebm() { + Assume.assumeTrue("FFmpeg native libraries required", FfmpegAvailability.available) + val webm = File("assets/sintel.webm") + Assume.assumeTrue("sintel.webm fixture required", webm.exists()) + + val info = MediaInfoExtractor.extract(webm)!! + assertEquals(854, info.width) + assertEquals(480, info.height) + assertEquals("vp9", info.videoCodec) + assertNotNull("frame rate should be probed", info.frameRate) + assertNotNull("source pixel format should be probed", info.pixelFormat) + // The webm also carries an Opus audio stream. + assertEquals("opus", info.codec) + assertNotNull("audio channels should be probed", info.channels) + } + + private fun generateSineWav(durationSec: Double, sampleRate: Int, freq: Double): File { + val sampleCount = (durationSec * sampleRate).toInt() + val dataBytes = sampleCount * 2 // s16 mono + val buf = ByteBuffer.allocate(44 + dataBytes).order(ByteOrder.LITTLE_ENDIAN) + buf.put("RIFF".toByteArray()) + buf.putInt(36 + dataBytes) + buf.put("WAVE".toByteArray()) + buf.put("fmt ".toByteArray()) + buf.putInt(16) + buf.putShort(1) // PCM + buf.putShort(1) // mono + buf.putInt(sampleRate) + buf.putInt(sampleRate * 2) + buf.putShort(2) + buf.putShort(16) + buf.put("data".toByteArray()) + buf.putInt(dataBytes) + for (i in 0 until sampleCount) { + buf.putShort((sin(2 * PI * freq * i / sampleRate) * 30000).toInt().toShort()) + } + val file = Files.createTempFile("jetplay-mediainfo-test", ".wav").toFile() + file.writeBytes(buf.array()) + return file + } +} diff --git a/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt b/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt new file mode 100644 index 00000000..842ec060 --- /dev/null +++ b/src/test/kotlin/dev/twango/jetplay/transcode/WaveformExtractorTest.kt @@ -0,0 +1,68 @@ +package dev.twango.jetplay.transcode + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume +import org.junit.Test +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import kotlin.math.PI +import kotlin.math.sin + +class WaveformExtractorTest { + + @Test + fun extractsNormalizedBarsFromAudio() { + Assume.assumeTrue("FFmpeg native libraries required", FfmpegAvailability.available) + val wav = generateSineWav(durationSec = 2.0, sampleRate = 8000, freq = 440.0) + try { + val bars = WaveformExtractor.extract(wav, barsPerSecond = 8) + + // ~2s * 8 bars/s = ~16 bars (allow slack for the trailing partial bar) + assertTrue("expected ~16 bars, got ${bars.size}", bars.size in 13..19) + assertTrue("every bar must be normalized to [0,1]", bars.all { it in 0.0..1.0 }) + assertTrue("a sustained sine must read as non-silent", bars.count { it > 0.1 } >= bars.size - 2) + } finally { + wav.delete() + } + } + + @Test + fun returnsEmptyForSilentlyUnreadableInput() { + Assume.assumeTrue("FFmpeg native libraries required", FfmpegAvailability.available) + val notAudio = Files.createTempFile("jetplay-not-audio", ".bin").toFile() + notAudio.writeBytes(ByteArray(2048) { 0x7f }) + try { + assertEquals(emptyList(), WaveformExtractor.extract(notAudio, barsPerSecond = 8)) + } finally { + notAudio.delete() + } + } + + private fun generateSineWav(durationSec: Double, sampleRate: Int, freq: Double): File { + val sampleCount = (durationSec * sampleRate).toInt() + val dataBytes = sampleCount * 2 // s16 mono + val buf = ByteBuffer.allocate(44 + dataBytes).order(ByteOrder.LITTLE_ENDIAN) + buf.put("RIFF".toByteArray()) + buf.putInt(36 + dataBytes) + buf.put("WAVE".toByteArray()) + buf.put("fmt ".toByteArray()) + buf.putInt(16) + buf.putShort(1) // PCM + buf.putShort(1) // mono + buf.putInt(sampleRate) + buf.putInt(sampleRate * 2) + buf.putShort(2) + buf.putShort(16) + buf.put("data".toByteArray()) + buf.putInt(dataBytes) + for (i in 0 until sampleCount) { + buf.putShort((sin(2 * PI * freq * i / sampleRate) * 30000).toInt().toShort()) + } + val file = Files.createTempFile("jetplay-wave-test", ".wav").toFile() + file.writeBytes(buf.array()) + return file + } +} diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..b6112051 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "nova", + "iconLibrary": "lucide", + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/ui/package-lock.json b/ui/package-lock.json index bfee8831..1a6014c2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,15 +8,23 @@ "name": "ui", "version": "0.0.0", "devDependencies": { - "@lucide/svelte": "^1.3.0", + "@fontsource-variable/geist": "^5.2.9", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", "@playwright/test": "^1.59.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.0", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", "svelte": "^5.53.12", "svelte-check": "^4.4.5", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "^8.0.5", "vite-plugin-singlefile": "^2.3.2" @@ -56,6 +64,54 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -107,9 +163,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.3.0.tgz", - "integrity": "sha512-ZRjfWKklbD9Sjhnb6d7v8A3zSGwVHNTM8kOiObSUIiF4B2rU4zskWV7ysp5tY0wOPl7ttj2bXGQvAd6qOCVhIA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.17.0.tgz", + "integrity": "sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==", "dev": true, "license": "ISC", "peerDependencies": { @@ -828,6 +884,16 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1189,6 +1255,31 @@ "node": ">= 0.4" } }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1228,6 +1319,16 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1238,6 +1339,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1340,6 +1451,13 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1638,6 +1756,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1704,6 +1832,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1905,6 +2040,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1918,6 +2078,25 @@ "node": ">=6" } }, + "node_modules/shadcn-svelte": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.3.0.tgz", + "integrity": "sha512-Pd4ICWTkTks/b2YU4c9vF2XsX1x5HFPRl5bKszS1LcnWS83x+7T4WiIvbWz8Qh9knkcGZ+SCz1+Dmhdq+AYooA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.0", + "node-fetch-native": "^1.6.4", + "postcss": "^8.5.5", + "tailwind-merge": "^3.0.0" + }, + "bin": { + "shadcn-svelte": "dist/index.mjs" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1928,6 +2107,16 @@ "node": ">=0.10.0" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/svelte": { "version": "5.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", @@ -1980,6 +2169,65 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -2036,8 +2284,17 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } }, "node_modules/typescript": { "version": "5.9.3", diff --git a/ui/package.json b/ui/package.json index 91b02ae6..af457b22 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,15 +12,23 @@ "test:ui": "playwright test --ui" }, "devDependencies": { - "@lucide/svelte": "^1.3.0", + "@fontsource-variable/geist": "^5.2.9", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", "@playwright/test": "^1.59.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.0", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", "svelte": "^5.53.12", "svelte-check": "^4.4.5", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "^8.0.5", "vite-plugin-singlefile": "^2.3.2" diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 87f48c53..af17c601 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -1,16 +1,20 @@ import { defineConfig } from '@playwright/test' +// Override with JETPLAY_TEST_PORT when 5173 is taken by another dev server — +// otherwise reuseExistingServer would latch onto that foreign server. +const port = Number(process.env.JETPLAY_TEST_PORT ?? 5173) + export default defineConfig({ testDir: './tests', timeout: 15_000, retries: process.env.CI ? 1 : 0, use: { - baseURL: 'http://localhost:5173', + baseURL: `http://localhost:${port}`, browserName: 'chromium', }, webServer: { - command: 'npm run dev', - port: 5173, + command: `npm run dev -- --port ${port} --strictPort`, + port, reuseExistingServer: !process.env.CI, }, }) \ No newline at end of file diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 05184512..ebe86348 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -7,11 +7,26 @@ const config = window.jetplay ?? {} - let state = $state(config.state ?? 'ready') - let progress = $state(0) - let downloadProgress = $state(0) - let mediaUrl = $state(config.mediaUrl ?? '') - let errorMessage = $state(config.errorMessage ?? 'An unknown error occurred') + // The IDE may push a state transition (mediaReady/error/progress) before these + // handlers exist — a fast transcode can finish before the page mounts — in + // which case the call is a silent no-op. It stashes the latest on window, so + // we seed from the stash here to avoid getting stuck on the loading screen. + let state = $state( + window.__jetplayReadyUrl + ? 'ready' + : window.__jetplayError + ? 'error' + : (window.__jetplayState ?? config.state ?? 'ready'), + ) + let progress = $state(window.__jetplayProgress ?? 0) + let downloadProgress = $state(window.__jetplayDownloadProgress ?? 0) + let mediaUrl = $state(window.__jetplayReadyUrl ?? config.mediaUrl ?? '') + let errorMessage = $state(window.__jetplayError ?? config.errorMessage ?? 'An unknown error occurred') + // Prefer a buffered push (the IDE may have called jetplayWaveform before this + // handler existed, in which case it stashed the bars on window). + let waveform = $state(window.__jetplayWaveform ?? config.waveform ?? []) + // Same buffered-push pattern for the codec inspector metadata. + let mediaInfo = $state(window.__jetplayMediaInfo ?? config.mediaInfo) const fileName = config.fileName ?? 'Unknown' const fileExtension = config.fileExtension ?? '' @@ -42,6 +57,17 @@ errorMessage = message state = 'error' } + + // FFmpeg-decoded amplitude bars pushed from the IDE (cheaper than decoding the + // whole file in the browser). + window.jetplayWaveform = (bars: number[]) => { + waveform = bars + } + + // FFmpeg-probed technical metadata pushed from the IDE for the codec inspector. + window.jetplayMediaInfo = (info: MediaInfo) => { + mediaInfo = info + } {#if state === 'downloading'} @@ -51,7 +77,7 @@ {:else if state === 'error'} {:else if isVideo} - + {:else} - + {/if} diff --git a/ui/src/app.css b/ui/src/app.css index 006c37f0..34495434 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1,4 +1,13 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn-svelte/tailwind.css"; +@import "@fontsource-variable/geist"; + +/* jetplay has no `.dark` class — it tracks the IDE/OS theme via + prefers-color-scheme, with the dark palette as the :root default (see below). + So `dark:` utilities in sv11 components must be active whenever we're NOT in + light mode (i.e. dark preference OR no preference), matching the token setup. */ +@custom-variant dark (@media not (prefers-color-scheme: light)); @theme { --color-surface: #2b2b2b; @@ -8,6 +17,41 @@ --color-accent: #4a88c7; --color-error: #e05555; --color-border: #404040; + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } @layer base { @@ -15,6 +59,7 @@ margin: 0; padding: 0; box-sizing: border-box; + @apply border-border outline-ring/50; } html, @@ -44,6 +89,87 @@ --color-accent: #2675bf; --color-error: #c44040; --color-border: #d0d0d0; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } } + body { + @apply bg-background text-foreground; + } + body { + @apply font-sans; + } +} + +/* + * jetplay tracks the IDE/OS theme via `prefers-color-scheme`, not a `.dark` + * class. So shadcn's dark palette is the `:root` default (the player opens + * dark by default, like the IDE), and the light palette overrides it in the + * `prefers-color-scheme: light` block above. This mirrors how the jetplay + * `--color-*` tokens are themed. + */ +:root { + /* radius is theme-invariant — define once here so the dark default has it + (it must not live only in the light @media block, or dark rounding breaks) */ + --radius: 0.625rem; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts index 6a9b6944..ef1f05fb 100644 --- a/ui/src/global.d.ts +++ b/ui/src/global.d.ts @@ -1,6 +1,28 @@ export {} declare global { + // Technical metadata for the codec inspector. Every field is optional — the + // IDE omits anything FFmpeg couldn't determine, and the UI skips it. + interface MediaInfo { + codec?: string + container?: string + sampleRateHz?: number + channels?: number + channelLabel?: string + bitDepth?: string + bitrateBps?: number + durationMs?: number + sizeBytes?: number + width?: number + height?: number + frameRate?: number + videoCodec?: string + pixelFormat?: string + videoBitrateBps?: number + tags?: { label: string; value: string }[] + albumArt?: string + } + interface Window { jetplay?: { mediaUrl?: string @@ -11,6 +33,8 @@ declare global { errorMessage?: string transcodingReason?: string downloadingReason?: string + waveform?: number[] + mediaInfo?: MediaInfo ui?: { downloadingLabel?: string transcodingLabel?: string @@ -23,6 +47,16 @@ declare global { jetplayStartTranscoding?: () => void jetplayReady?: (mediaUrl: string) => void jetplayError?: (message: string) => void + jetplayWaveform?: (bars: number[]) => void + __jetplayWaveform?: number[] + // Buffered state pushes (read on mount so an early transition isn't dropped). + __jetplayReadyUrl?: string + __jetplayError?: string + __jetplayState?: 'downloading' | 'loading' | 'ready' | 'error' + __jetplayProgress?: number + __jetplayDownloadProgress?: number + jetplayMediaInfo?: (info: MediaInfo) => void + __jetplayMediaInfo?: MediaInfo jetplayOpenLink?: (url: string) => void } } \ No newline at end of file diff --git a/ui/src/lib/AudioPlayer.svelte b/ui/src/lib/AudioPlayer.svelte index 257b42eb..a62a9173 100644 --- a/ui/src/lib/AudioPlayer.svelte +++ b/ui/src/lib/AudioPlayer.svelte @@ -1,128 +1,22 @@ - -
- - - -
- -
- - -
- - {fileName} - - {#if extension} - - {extension} - - {/if} -
- - -
- (audioEl.currentTime = t)} - /> -
- - -
- - - -
- - - - - -
\ No newline at end of file + + + + diff --git a/ui/src/lib/AudioPlayerBody.svelte b/ui/src/lib/AudioPlayerBody.svelte new file mode 100644 index 00000000..ad983c84 --- /dev/null +++ b/ui/src/lib/AudioPlayerBody.svelte @@ -0,0 +1,456 @@ + + + +
+ {#if albumArt} + + + {/if} +
+ +
+ {#snippet nameAndBadge()} + {fileName} + {#if extension} + + {extension} + + {/if} + {/snippet} + + {#if hasMediaInfo} + + {:else} +
+ {@render nameAndBadge()} +
+ {/if} + + {#if summaryLine} +
+ {summaryLine} +
+ {/if} + + {#if hasMediaInfo && infoExpanded} +
+ {#if tags.length > 0} + +
+ {#each tags as tag (tag.label)} + {tag.label} + {tag.value} + {/each} +
+ {/if} + {#if infoRows.length > 0} + +
+ {#each infoRows as row (row.label)} + {row.label} + {row.value} + {/each} +
+ {/if} +
+ {/if} +
+ + + {#if hasWaveform} +
+ +
+
+
+ +
+
+
+
+ {/if} + + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + setVolume(v)} + class="group/vol relative flex h-4 w-40 touch-none items-center select-none" + > + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + + {Math.round((muted ? 0 : volume) * 100)}% +
+
+ + +
diff --git a/ui/src/lib/SeekBar.svelte b/ui/src/lib/SeekBar.svelte deleted file mode 100644 index 9a8a7f75..00000000 --- a/ui/src/lib/SeekBar.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
- -
-
-
-
-
- {#if showTime} -
- {formatTime(currentTime)} - {formatTime(duration)} -
- {/if} -
- - \ No newline at end of file diff --git a/ui/src/lib/VideoPlayer.svelte b/ui/src/lib/VideoPlayer.svelte index 0470f08f..21628f3d 100644 --- a/ui/src/lib/VideoPlayer.svelte +++ b/ui/src/lib/VideoPlayer.svelte @@ -1,60 +1,232 @@
{ + togglePlay() + containerEl.focus() + }} ontimeupdate={() => (currentTime = videoEl.currentTime)} - ondurationchange={() => (duration = videoEl.duration)} - onplay={() => { paused = false; showControls() }} - onpause={() => { paused = true; controlsVisible = true }} + ondurationchange={() => (duration = Number.isFinite(videoEl.duration) ? videoEl.duration : 0)} + onloadedmetadata={() => (duration = Number.isFinite(videoEl.duration) ? videoEl.duration : 0)} + onplay={() => { + paused = false + showControls() + }} + onpause={() => { + paused = true + controlsVisible = true + }} > +
-
-
- (videoEl.currentTime = t)} - showTime={false} - trackClass="bg-white/25" - fillClass="bg-white/70" - /> -
- -
+
+ {#if hasMediaInfo} + {:else} +
+ {fileName} + {#if extension} + + {extension} + + {/if} +
+ {/if} +
+
+ + + {#if hasMediaInfo && infoExpanded} + {@const group = 'grid grid-cols-[auto_1fr] gap-x-4 gap-y-1'} +
+ {#if videoRows.length} +
+ {#each videoRows as r (r.label)} + {r.label} + {r.value} + {/each} +
+ {/if} + {#if audioRows.length} +
+ {#each audioRows as r (r.label)} + {r.label} + {r.value} + {/each} +
+ {/if} + {#if generalRows.length} +
+ {#each generalRows as r (r.label)} + {r.label} + {r.value} + {/each} +
+ {/if} + {#if tags.length} +
+ {#each tags as t (t.label)} + {t.label} + {t.value} + {/each} +
+ {/if} +
+ {/if} + + +
+
+ + + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + + + +
+ + + + + - - - + {formatTime(currentTime)} / {formatTime(duration)} + +
+ + setVolume(v)} + class="group/vol relative flex h-4 w-16 touch-none items-center select-none" + > + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + +
+ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#each PLAYBACK_SPEEDS as speed (speed)} + setSpeed(speed)} class="flex items-center justify-between"> + {speed === 1 ? 'Normal' : `${speed}x`} + {#if playbackRate === speed} + + {/if} + + {/each} + +
-
\ No newline at end of file +
diff --git a/ui/src/lib/VolumeControl.svelte b/ui/src/lib/VolumeControl.svelte deleted file mode 100644 index 1ceb6a71..00000000 --- a/ui/src/lib/VolumeControl.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - -
- - - -
-
-
-
-
-
\ No newline at end of file diff --git a/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts b/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts new file mode 100644 index 00000000..dbc1277c --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts @@ -0,0 +1,58 @@ +/** + * Lazy shared Web Audio graph for an audio element. Splits context creation + * from analyser wiring so non-analyser callers (e.g. a scratch synth) can warm + * the context from a user gesture without forcing a `createMediaElementSource` + * call — only one is legal per element lifetime. + */ +export class AudioGraph { + audioContext = $state(null); + analyser = $state(null); + #source: MediaElementAudioSourceNode | null = null; + + ensureContext(): AudioContext | null { + if (this.audioContext) return this.audioContext; + try { + const AC = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + this.audioContext = new AC(); + } catch (err) { + console.error("AudioContext creation failed", err); + return null; + } + return this.audioContext; + } + + ensureAnalyser(audioEl: HTMLAudioElement): AnalyserNode | null { + if (this.analyser) return this.analyser; + const ctx = this.ensureContext(); + if (!ctx) return null; + if (ctx.state === "suspended") void ctx.resume().catch(() => {}); + try { + this.#source = ctx.createMediaElementSource(audioEl); + const a = ctx.createAnalyser(); + a.fftSize = 512; + a.smoothingTimeConstant = 0.7; + this.#source.connect(a); + a.connect(ctx.destination); + this.analyser = a; + return a; + } catch (err) { + console.error("analyser wire failed", err); + return null; + } + } + + destroy(): void { + try { + this.#source?.disconnect(); + this.analyser?.disconnect(); + } catch { + // nodes may already be gone + } + void this.audioContext?.close().catch(() => {}); + this.audioContext = null; + this.analyser = null; + this.#source = null; + } +} diff --git a/ui/src/lib/components/ui/audio-player/audio-player-button.svelte b/ui/src/lib/components/ui/audio-player/audio-player-button.svelte new file mode 100644 index 00000000..510afc2d --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-button.svelte @@ -0,0 +1,72 @@ + + + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte b/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte new file mode 100644 index 00000000..21deda18 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte @@ -0,0 +1,26 @@ + + + + {display} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte b/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte new file mode 100644 index 00000000..44405e5b --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte @@ -0,0 +1,107 @@ + + + { + if (!userInteracting) return; + player.seek(v); + externalOnValueChange?.(v); + }} + onpointerdown={(e) => { + userInteracting = true; + wasPlaying = player.isPlaying; + void player.pause(); + externalPointerDown?.(e); + }} + onpointerup={(e) => { + userInteracting = false; + if (wasPlaying) void player.play(); + externalPointerUp?.(e); + }} + onpointercancel={(e) => { + userInteracting = false; + if (wasPlaying) void player.play(); + externalPointerCancel?.(e); + }} + onkeydown={(e) => { + if (e.key === " ") { + e.preventDefault(); + if (player.isPlaying) void player.pause(); + else void player.play(); + } else { + userInteracting = true; + queueMicrotask(() => { + userInteracting = false; + }); + } + externalKeyDown?.(e); + }} + class={cn( + "group/player relative flex h-4 touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-44 data-vertical:w-auto data-vertical:flex-col", + className + )} + {...restProps} +> + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte b/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte new file mode 100644 index 00000000..18893895 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte @@ -0,0 +1,33 @@ + + +
+ {#each speeds as speed (speed)} + + {/each} +
diff --git a/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte b/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte new file mode 100644 index 00000000..32d3e779 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte @@ -0,0 +1,57 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#each speeds as speed (speed)} + player.setPlaybackRate(speed)} + class="flex items-center justify-between" + > + + {speed === 1 ? "Normal" : `${speed}x`} + + {#if player.playbackRate === speed} + + {/if} + + {/each} + + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-time.svelte b/ui/src/lib/components/ui/audio-player/audio-player-time.svelte new file mode 100644 index 00000000..0db7c397 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-time.svelte @@ -0,0 +1,18 @@ + + + + {formatTime(player.time)} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player.svelte b/ui/src/lib/components/ui/audio-player/audio-player.svelte new file mode 100644 index 00000000..b8c83600 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player.svelte @@ -0,0 +1,72 @@ + + + + + + +{@render children?.()} diff --git a/ui/src/lib/components/ui/audio-player/context.svelte.ts b/ui/src/lib/components/ui/audio-player/context.svelte.ts new file mode 100644 index 00000000..f7ca0ba2 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/context.svelte.ts @@ -0,0 +1,123 @@ +import { getContext, setContext } from "svelte"; + +const AUDIO_PLAYER_CONTEXT_KEY = Symbol("sv11-audio-player"); + +export interface AudioPlayerItem { + id: string | number; + src: string; + data?: TData; +} + +export class AudioPlayerState { + audio: HTMLAudioElement | null = $state(null); + activeItem: AudioPlayerItem | null = $state(null); + time = $state(0); + duration = $state(undefined); + paused = $state(true); + playbackRate = $state(1); + readyState = $state(0); + networkState = $state(0); + error: MediaError | null = $state(null); + + isBuffering = $derived(this.readyState < 3 && this.networkState === 2); + isPlaying = $derived(!this.paused); + + #playPromise: Promise | null = null; + + isItemActive = (id: string | number | null): boolean => { + if (id === null) return this.activeItem === null; + return this.activeItem?.id === id; + }; + + #swapTrack = (item: AudioPlayerItem | null): void => { + const audio = this.audio; + if (!audio) return; + this.activeItem = item; + const currentRate = audio.playbackRate; + if (!audio.paused) audio.pause(); + audio.currentTime = 0; + if (item === null) { + audio.removeAttribute("src"); + } else { + audio.src = item.src; + } + audio.load(); + audio.playbackRate = currentRate; + }; + + setActiveItem = async (item: AudioPlayerItem | null): Promise => { + if (!this.audio) return; + if ((item?.id ?? null) === (this.activeItem?.id ?? null)) return; + this.#swapTrack(item); + }; + + play = async (item?: AudioPlayerItem | null): Promise => { + const audio = this.audio; + if (!audio) return; + + if (this.#playPromise) { + try { + await this.#playPromise; + } catch (error) { + console.error("Play promise error:", error); + } + } + + if (item === undefined) { + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + } + if ((item?.id ?? null) === (this.activeItem?.id ?? null)) { + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + } + + this.#swapTrack(item); + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + }; + + pause = async (): Promise => { + const audio = this.audio; + if (!audio) return; + + if (this.#playPromise) { + try { + await this.#playPromise; + } catch (e) { + console.error(e); + } + } + + audio.pause(); + this.#playPromise = null; + }; + + seek = (time: number): void => { + if (!this.audio) return; + this.audio.currentTime = time; + }; + + setPlaybackRate = (rate: number): void => { + if (!this.audio) return; + this.playbackRate = rate; + this.audio.playbackRate = rate; + }; +} + +export function setAudioPlayer(): AudioPlayerState { + const state = new AudioPlayerState(); + setContext(AUDIO_PLAYER_CONTEXT_KEY, state); + return state; +} + +export function useAudioPlayer(): AudioPlayerState { + const ctx = getContext | undefined>(AUDIO_PLAYER_CONTEXT_KEY); + if (!ctx) { + throw new Error("useAudioPlayer cannot be called outside of an "); + } + return ctx; +} diff --git a/ui/src/lib/components/ui/audio-player/example-tracks.ts b/ui/src/lib/components/ui/audio-player/example-tracks.ts new file mode 100644 index 00000000..c91b101d --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/example-tracks.ts @@ -0,0 +1,29 @@ +export type ExampleTrack = { + id: string; + name: string; + url: string; + /** Precomputed waveform bars inlined. Highest priority. */ + waveform?: number[]; + /** URL to a JSON file containing precomputed bars. Second priority. */ + waveformUrl?: string; +}; + +const TRACK_NAMES = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliett", +] as const; + +export const exampleTracks: ExampleTrack[] = TRACK_NAMES.map((name, i) => ({ + id: String(i), + name: name.charAt(0).toUpperCase() + name.slice(1), + url: `https://sv11.ui.twango.dev/audio/${name}.mp3`, + waveformUrl: `https://sv11.ui.twango.dev/audio/waveforms/${name}.json`, +})); diff --git a/ui/src/lib/components/ui/audio-player/index.ts b/ui/src/lib/components/ui/audio-player/index.ts new file mode 100644 index 00000000..7c52d602 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/index.ts @@ -0,0 +1,32 @@ +import Root from "./audio-player.svelte"; +import Button from "./audio-player-button.svelte"; +import Progress from "./audio-player-progress.svelte"; +import Time from "./audio-player-time.svelte"; +import Duration from "./audio-player-duration.svelte"; +import Speed from "./audio-player-speed.svelte"; +import SpeedButtonGroup from "./audio-player-speed-button-group.svelte"; + +export { + Root, + Button, + Progress, + Time, + Duration, + Speed, + SpeedButtonGroup, + // + Root as AudioPlayer, + Button as AudioPlayerButton, + Progress as AudioPlayerProgress, + Time as AudioPlayerTime, + Duration as AudioPlayerDuration, + Speed as AudioPlayerSpeed, + SpeedButtonGroup as AudioPlayerSpeedButtonGroup, +}; + +export { setAudioPlayer, useAudioPlayer, AudioPlayerState } from "./context.svelte.js"; +export type { AudioPlayerItem } from "./context.svelte.js"; +export { formatTime } from "./utils.js"; +export { exampleTracks } from "./example-tracks.js"; +export { precomputeWaveform, sampleWaveform } from "./waveform-sampler.js"; +export { AudioGraph } from "./audio-graph.svelte.js"; diff --git a/ui/src/lib/components/ui/audio-player/utils.ts b/ui/src/lib/components/ui/audio-player/utils.ts new file mode 100644 index 00000000..49d3b436 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/utils.ts @@ -0,0 +1,10 @@ +export function formatTime(seconds: number): string { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const formattedMins = mins < 10 ? `0${mins}` : mins; + const formattedSecs = secs < 10 ? `0${secs}` : secs; + + return hrs > 0 ? `${hrs}:${formattedMins}:${formattedSecs}` : `${mins}:${formattedSecs}`; +} diff --git a/ui/src/lib/components/ui/audio-player/waveform-sampler.ts b/ui/src/lib/components/ui/audio-player/waveform-sampler.ts new file mode 100644 index 00000000..4d180a34 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/waveform-sampler.ts @@ -0,0 +1,64 @@ +/** + * Default rate used when callers don't pass one. Constant-rate sampling (as + * opposed to a fixed total bar count) keeps scroll speed and detail identical + * across songs of different lengths. + */ +export const DEFAULT_BARS_PER_SECOND = 8; + +/** + * Pure sampler: given a mono Float32 PCM channel, returns `bars` normalized + * amplitude values in `[0, 1]`. The algorithm walks every 100th sample in each + * bucket to stay cheap and multiplies by 3 so quiet tracks still read visually. + * + * Values are quantized to 2 decimal places — bars render at ≤51 physical px at + * 2× DPR, so finer precision isn't visible and 2dp cuts shipped JSON size ~3×. + * + * The same function runs in both the browser (inside `precomputeWaveform` + * below) and the Vite plugin (`vite/waveforms-plugin.ts`) so shipped JSONs + * and the runtime fallback agree by construction. + */ +export function sampleWaveform(channelData: Float32Array, bars: number): number[] { + const samplesPerBar = Math.floor(channelData.length / bars); + const out: number[] = []; + for (let i = 0; i < bars; i++) { + const start = i * samplesPerBar; + const end = start + samplesPerBar; + let sum = 0; + let count = 0; + for (let j = start; j < end && j < channelData.length; j += 100) { + sum += Math.abs(channelData[j]); + count++; + } + const avg = count > 0 ? sum / count : 0; + out.push(Math.round(Math.min(1, avg * 3) * 100) / 100); + } + return out; +} + +/** + * Browser fallback: fetch an audio URL, decode it via `OfflineAudioContext`, + * and sample the first channel at `barsPerSecond` amplitude values per second + * of audio. + * + * Prefer shipping precomputed waveform JSONs (via the Vite plugin) and + * pointing tracks at them with `waveformUrl`. This function is the lazy + * fallback for tracks without one. + */ +export async function precomputeWaveform( + url: string, + barsPerSecond = DEFAULT_BARS_PER_SECOND +): Promise { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const OfflineCtx = + window.OfflineAudioContext || + (window as unknown as { webkitOfflineAudioContext: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + // Length is irrelevant — we only use the context to invoke decodeAudioData, + // which returns an AudioBuffer sized to the source. Use the minimum valid + // value to make that intent obvious. + const offlineContext = new OfflineCtx(1, 1, 44100); + const audioBuffer = await offlineContext.decodeAudioData(arrayBuffer.slice(0)); + const bars = Math.max(1, Math.round(audioBuffer.duration * barsPerSecond)); + return sampleWaveform(audioBuffer.getChannelData(0), bars); +} diff --git a/ui/src/lib/components/ui/button/button.svelte b/ui/src/lib/components/ui/button/button.svelte new file mode 100644 index 00000000..cfadfd61 --- /dev/null +++ b/ui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/button/index.ts b/ui/src/lib/components/ui/button/index.ts new file mode 100644 index 00000000..fb585d76 --- /dev/null +++ b/ui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 00000000..e0e19718 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 00000000..d8d0df6a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else if checked} + + {/if} + + {@render childrenProp?.({ checked, indeterminate })} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 00000000..261a5b08 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 00000000..433540fd --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 00000000..aca1f7bd --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 00000000..c425190d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 00000000..a72e599a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 00000000..274cfef7 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 00000000..189aef40 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 00000000..c8aa07b1 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 00000000..90f1b6f1 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 00000000..ed7cc85a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 00000000..b5750d4d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 00000000..fab0275d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 00000000..f0445813 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 00000000..cb053444 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 00000000..cb4bc621 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/index.ts b/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 00000000..7850c6a3 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/ui/src/lib/components/ui/waveform/index.ts b/ui/src/lib/components/ui/waveform/index.ts new file mode 100644 index 00000000..ea707ca7 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/index.ts @@ -0,0 +1,33 @@ +import Root from "./waveform.svelte"; +import Scrolling from "./waveform-scrolling.svelte"; +import Scrubber from "./waveform-scrubber.svelte"; +import Microphone from "./waveform-microphone.svelte"; +import Static from "./waveform-static.svelte"; +import LiveMicrophone from "./waveform-live-microphone.svelte"; +import Recording from "./waveform-recording.svelte"; + +export { + Root, + Scrolling, + Scrubber, + Microphone, + Static, + LiveMicrophone, + Recording, + // + Root as Waveform, + Scrolling as ScrollingWaveform, + Scrubber as AudioScrubber, + Microphone as MicrophoneWaveform, + Static as StaticWaveform, + LiveMicrophone as LiveMicrophoneWaveform, + Recording as RecordingWaveform, +}; +export type { WaveformProps } from "./waveform.svelte"; +export type { ScrollingWaveformProps } from "./waveform-scrolling.svelte"; +export type { AudioScrubberProps } from "./waveform-scrubber.svelte"; +export type { MicrophoneWaveformProps } from "./waveform-microphone.svelte"; +export type { StaticWaveformProps } from "./waveform-static.svelte"; +export type { LiveMicrophoneWaveformProps } from "./waveform-live-microphone.svelte"; +export type { RecordingWaveformProps } from "./waveform-recording.svelte"; +export { seededRandom, heightToCssSize, getComputedBarColor } from "./utils.js"; diff --git a/ui/src/lib/components/ui/waveform/utils.ts b/ui/src/lib/components/ui/waveform/utils.ts new file mode 100644 index 00000000..945cdc8b --- /dev/null +++ b/ui/src/lib/components/ui/waveform/utils.ts @@ -0,0 +1,15 @@ +export function seededRandom(seed: number): number { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +export function heightToCssSize(height: string | number): string { + return typeof height === "number" ? `${height}px` : height; +} + +export function getComputedBarColor( + canvas: HTMLCanvasElement, + override: string | undefined +): string { + return override || getComputedStyle(canvas).getPropertyValue("--foreground") || "#000"; +} diff --git a/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte b/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte new file mode 100644 index 00000000..944d1a78 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte @@ -0,0 +1,549 @@ + + + +
0 && "cursor-pointer", + className + )} + role={!active && historyRef.current.length > 0 ? "slider" : undefined} + aria-label={!active && historyRef.current.length > 0 + ? "Drag to scrub through recording" + : undefined} + aria-valuenow={!active && historyRef.current.length > 0 ? Math.abs(dragOffset) : undefined} + aria-valuemin={!active && historyRef.current.length > 0 ? 0 : undefined} + aria-valuemax={!active && historyRef.current.length > 0 ? historyRef.current.length : undefined} + tabindex={!active && historyRef.current.length > 0 ? 0 : undefined} + style:height={heightStyle} + onpointerdown={handlePointerDown} + {...restProps} +> + +
diff --git a/ui/src/lib/components/ui/waveform/waveform-microphone.svelte b/ui/src/lib/components/ui/waveform/waveform-microphone.svelte new file mode 100644 index 00000000..c81535c2 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-microphone.svelte @@ -0,0 +1,213 @@ + + + diff --git a/ui/src/lib/components/ui/waveform/waveform-recording.svelte b/ui/src/lib/components/ui/waveform/waveform-recording.svelte new file mode 100644 index 00000000..de275f5e --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-recording.svelte @@ -0,0 +1,311 @@ + + + +
+ +
diff --git a/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte b/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte new file mode 100644 index 00000000..c7d1fb4d --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte @@ -0,0 +1,207 @@ + + +
+ +
diff --git a/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte b/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte new file mode 100644 index 00000000..0cd0367b --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+ +
+ + {#if showHandle} +
+ {/if} +
diff --git a/ui/src/lib/components/ui/waveform/waveform-static.svelte b/ui/src/lib/components/ui/waveform/waveform-static.svelte new file mode 100644 index 00000000..1f5025f0 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-static.svelte @@ -0,0 +1,15 @@ + + + diff --git a/ui/src/lib/components/ui/waveform/waveform.svelte b/ui/src/lib/components/ui/waveform/waveform.svelte new file mode 100644 index 00000000..2eca6b19 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform.svelte @@ -0,0 +1,198 @@ + + +
+ +
diff --git a/ui/src/lib/mediaInfoFormat.ts b/ui/src/lib/mediaInfoFormat.ts new file mode 100644 index 00000000..455220a3 --- /dev/null +++ b/ui/src/lib/mediaInfoFormat.ts @@ -0,0 +1,29 @@ +// Shared formatters for the codec inspector, used by both the audio and video +// players so the two render identical-looking values. + +export function formatSampleRate(hz: number): string { + // Keep enough precision for the conventional rates (44.1, 22.05, 11.025) while + // trimming trailing zeros (48000 → "48", 44100 → "44.1"). + const k = Number((hz / 1000).toFixed(3)) + return `${k} kHz` +} + +export function formatBitrate(bps: number): string { + // kbps is the conventional unit at every magnitude (lossless audio and video + // can run several thousand kbps), so we don't switch to Mbps. + return `${Math.round(bps / 1000)} kbps` +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(kb < 10 ? 1 : 0)} KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB` + return `${(mb / 1024).toFixed(1)} GB` +} + +export function formatFrameRate(fps: number): string { + // 24 → "24 fps", 23.976023… → "23.976 fps", 29.97 → "29.97 fps". + return `${Number(fps.toFixed(3))} fps` +} diff --git a/ui/src/lib/use-scratchable-waveform.svelte.ts b/ui/src/lib/use-scratchable-waveform.svelte.ts new file mode 100644 index 00000000..6345bb33 --- /dev/null +++ b/ui/src/lib/use-scratchable-waveform.svelte.ts @@ -0,0 +1,258 @@ +import type { AudioGraph, AudioPlayerState } from "$lib/components/ui/audio-player/index.js"; + +// Module-level cache, intentionally non-reactive: keyed by track URL so +// navigating away and back reuses the decode, and concurrent pointerdowns +// dedupe onto one in-flight promise. +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const scratchBufferCache = new Map>(); + +async function fetchScratchBuffer(url: string, ctx: AudioContext): Promise { + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + return await ctx.decodeAudioData(arrayBuffer); + } catch (error) { + console.warn("Scratch buffer decode failed:", error); + return null; + } +} + +function getScratchBuffer(url: string, ctx: AudioContext): Promise { + let cached = scratchBufferCache.get(url); + if (!cached) { + cached = fetchScratchBuffer(url, ctx); + scratchBufferCache.set(url, cached); + // Don't permanently cache failures: evict so a later attempt can retry. + void cached.then((buf) => { + if (!buf) scratchBufferCache.delete(url); + }); + } + return cached; +} + +export interface ScratchableWaveformOptions { + player: AudioPlayerState; + graph: AudioGraph; + trackUrl: () => string | null; + totalWidth: () => number; + containerWidth: () => number; +} + +export function useScratchableWaveform(opts: ScratchableWaveformOptions) { + let isScrubbing = $state(false); + let isMomentumActive = $state(false); + let offset = $state(0); + + let scratchBuffer: AudioBuffer | null = null; + let scratchSource: AudioBufferSourceNode | null = null; + let scratchFilter: BiquadFilterNode | null = null; + let momentumRaf: number | null = null; + + function warmScratchBuffer(): void { + if (scratchBuffer) return; + const url = opts.trackUrl(); + const ctx = opts.graph.ensureContext(); + if (!url || !ctx) return; + void getScratchBuffer(url, ctx).then((buf) => { + if (buf && opts.trackUrl() === url) scratchBuffer = buf; + }); + } + + function setOffsetAndSeek(next: number): void { + offset = next; + const totalW = opts.totalWidth(); + if (totalW <= 0) return; + // Playhead pinned at the RIGHT edge (sv11): offset containerWidth = start, + // containerWidth - totalW = end. The waveform fills in from the right and + // scrolls left as it plays. + const position = Math.max(0, Math.min(1, (opts.containerWidth() - next) / totalW)); + const audio = opts.player.audio; + if (audio && isFinite(audio.duration)) audio.currentTime = position * audio.duration; + } + + function playScratch(position: number, speed: number): void { + const ctx = opts.graph.ensureContext(); + if (!ctx || !scratchBuffer) return; + if (ctx.state === "suspended") void ctx.resume().catch(() => {}); + stopScratch(); + try { + const source = ctx.createBufferSource(); + source.buffer = scratchBuffer; + const startTime = Math.max( + 0, + Math.min(scratchBuffer.duration - 0.1, position * scratchBuffer.duration) + ); + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = Math.max(800, 2500 - speed * 1500); + filter.Q.value = 3; + source.playbackRate.value = Math.max(0.4, Math.min(2.5, 1 + speed * 0.5)); + source.connect(filter); + filter.connect(ctx.destination); + source.start(0, startTime, 0.06); + scratchSource = source; + scratchFilter = filter; + } catch (error) { + console.error("scratch playback failed:", error); + } + } + + function stopScratch(): void { + if (!scratchSource && !scratchFilter) return; + try { + scratchSource?.stop(); + } catch { + // already stopped + } + scratchSource?.disconnect(); + scratchFilter?.disconnect(); + scratchSource = null; + scratchFilter = null; + } + + function stopMomentum(): void { + if (momentumRaf !== null) { + cancelAnimationFrame(momentumRaf); + momentumRaf = null; + } + isMomentumActive = false; + } + + function handlePointerDown(e: PointerEvent): void { + if (e.pointerType === "mouse" && e.button !== 0) return; + e.preventDefault(); + // Cancel any in-flight momentum so it doesn't race with the new drag. + stopMomentum(); + warmScratchBuffer(); + + isScrubbing = true; + const wasPlaying = opts.player.isPlaying; + if (wasPlaying) void opts.player.pause(); + + const startX = e.clientX; + const totalW = opts.totalWidth(); + const containerW = opts.containerWidth(); + const startOffset = offset; + // Right-pinned playhead: offset ranges [containerW - totalW, containerW]. + const clamp = (v: number) => Math.max(containerW - totalW, Math.min(containerW, v)); + const posAt = (o: number) => Math.max(0, Math.min(1, (containerW - o) / totalW)); + + let lastPointerX = startX; + let lastScratchTime = 0; + let velocity = 0; + let lastTime = Date.now(); + let lastClientX = e.clientX; + + const onMove = (ev: PointerEvent) => { + if (ev.pointerId !== e.pointerId) return; + const clamped = clamp(startOffset + (ev.clientX - startX)); + setOffsetAndSeek(clamped); + + const now = Date.now(); + const pointerDelta = ev.clientX - lastPointerX; + const timeDelta = now - lastTime; + if (timeDelta > 0) { + const instant = (ev.clientX - lastClientX) / timeDelta; + velocity = velocity * 0.6 + instant * 0.4; + } + lastTime = now; + lastClientX = ev.clientX; + + if (pointerDelta !== 0 && now - lastScratchTime >= 10) { + playScratch(posAt(clamped), Math.min(3, Math.abs(pointerDelta) / 3)); + lastScratchTime = now; + } + lastPointerX = ev.clientX; + }; + + const onEnd = (ev: PointerEvent) => { + if (ev.pointerId !== e.pointerId) return; + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onEnd); + document.removeEventListener("pointercancel", onEnd); + + isScrubbing = false; + stopScratch(); + + if (Math.abs(velocity) <= 0.1) { + if (wasPlaying) void opts.player.play(); + return; + } + + isMomentumActive = true; + let current = offset; + let v = velocity * 15; + let lastFrame = 0; + + const step = () => { + if (Math.abs(v) <= 0.5) { + stopScratch(); + momentumRaf = null; + isMomentumActive = false; + // Delay the resume so the final scratch slice releases + // before the media element starts — prevents a crackle. + if (wasPlaying) setTimeout(() => void opts.player.play(), 10); + return; + } + current += v; + v *= 0.92; + const clamped = clamp(current); + if (clamped !== current) v = 0; + current = clamped; + setOffsetAndSeek(clamped); + + const now = Date.now(); + if (now - lastFrame >= 50) { + const speed = Math.min(2.5, Math.abs(v) / 10); + if (speed > 0.1) playScratch(posAt(clamped), speed); + lastFrame = now; + } + momentumRaf = requestAnimationFrame(step); + }; + momentumRaf = requestAnimationFrame(step); + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onEnd); + document.addEventListener("pointercancel", onEnd); + } + + function handleKeyDown(e: KeyboardEvent): void { + const audio = opts.player.audio; + if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return; + const step = e.shiftKey ? 5 : 1; + const currentPct = (audio.currentTime / audio.duration) * 100; + let nextPct: number | null = null; + if (e.key === "ArrowLeft") nextPct = Math.max(0, currentPct - step); + else if (e.key === "ArrowRight") nextPct = Math.min(100, currentPct + step); + else if (e.key === "Home") nextPct = 0; + else if (e.key === "End") nextPct = 100; + if (nextPct === null) return; + e.preventDefault(); + audio.currentTime = (nextPct / 100) * audio.duration; + } + + function reset(): void { + scratchBuffer = null; + stopMomentum(); + stopScratch(); + } + + return { + get isScrubbing() { + return isScrubbing; + }, + get isMomentumActive() { + return isMomentumActive; + }, + get offset() { + return offset; + }, + set offset(value: number) { + offset = value; + }, + handlePointerDown, + handleKeyDown, + reset, + }; +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 00000000..0ec47788 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export type WithoutChild = T extends { child?: unknown } ? Omit : T; +export type WithoutChildren = T extends { children?: unknown } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/ui/tests/audio-player.spec.ts b/ui/tests/audio-player.spec.ts index 5ff0e498..cc8f295f 100644 --- a/ui/tests/audio-player.spec.ts +++ b/ui/tests/audio-player.spec.ts @@ -8,9 +8,190 @@ const audioConfig = { isVideo: false, } +// Regression guard: the IDE serves media as file:// into a null-origin +// loadHTML page, where `crossorigin` would fail the CORS check and the audio +// would never load. A re-pull of the sv11 component must not reintroduce it. +test('audio element has no crossorigin attribute', async ({ loadApp }) => { + const page = await loadApp(audioConfig) + const crossorigin = await page.locator('audio').getAttribute('crossorigin') + expect(crossorigin).toBeNull() +}) + +// The IDE decodes the waveform with FFmpeg and pushes the bars (the browser +// can't read file:// bytes). Use a non-decodable URL so the browser fallback +// can't mask the bridge path. +test('waveform bars pushed from the IDE render the waveform', async ({ loadApp }) => { + const page = await loadApp({ ...audioConfig, mediaUrl: '/assets/does-not-exist.mp3' }) + const waveform = page.locator('[aria-label="Seek playback"]') + await expect(waveform).toHaveCount(0) + + await page.evaluate(() => window.jetplayWaveform?.(Array.from({ length: 40 }, (_, i) => (i % 5) / 5))) + await expect(waveform).toBeVisible() +}) + +// IDE-pushed bars must win over the in-browser decode fallback. +test('pushed bars take precedence over the in-browser fallback', async ({ loadApp }) => { + const page = await loadApp(audioConfig) // decodable URL → fallback fills the bars + const waveform = page.locator('[aria-label="Seek playback"]') + await expect(waveform).toBeVisible() + await expect(async () => { + expect(Number(await waveform.getAttribute('data-bars'))).toBeGreaterThan(3) + }).toPass() + + await page.evaluate(() => window.jetplayWaveform?.([0.1, 0.2, 0.3])) + await expect(waveform).toHaveAttribute('data-bars', '3') +}) + +// A push that arrived before the page defined the handler is stashed on window +// and must still be picked up on mount. +test('a waveform buffered before load is picked up', async ({ page }) => { + await page.addInitScript(() => { + ;(window as any).jetplay = { + state: 'ready', + fileName: 'x.mp3', + fileExtension: 'mp3', + mediaUrl: '/assets/does-not-exist.mp3', + isVideo: false, + } + ;(window as any).__jetplayWaveform = [0.4, 0.5, 0.6, 0.7] + }) + await page.goto('/') + await expect(page.locator('[aria-label="Seek playback"]')).toBeVisible() +}) + +// The IDE probes the file with FFmpeg and pushes technical metadata; the header +// stays a plain filename until then, and expands into a full grid on click. +test('media-info push renders the summary and expands into a grid', async ({ loadApp }) => { + const page = await loadApp(audioConfig) + // No info yet → no toggle, no summary, no grid. + await expect(page.locator('[aria-label="Toggle media details"]')).toHaveCount(0) + await expect(page.locator('[data-slot="media-info-summary"]')).toHaveCount(0) + + await page.evaluate(() => + window.jetplayMediaInfo?.({ + codec: 'pcm_s16le', + container: 'wav', + sampleRateHz: 48000, + channels: 2, + channelLabel: 'stereo', + bitDepth: '16-bit', + bitrateBps: 1536000, + durationMs: 42318, + sizeBytes: 1258291, + }), + ) + + const summary = page.locator('[data-slot="media-info-summary"]') + await expect(summary).toBeVisible() + await expect(summary).toContainText('48 kHz') + await expect(summary).toContainText('stereo') + // Container is not duplicated in the summary — the badge already shows it. + await expect(summary).not.toContainText('WAV') + + // Grid is collapsed until the header is clicked. + await expect(page.locator('[data-slot="media-info-grid"]')).toHaveCount(0) + await page.locator('[aria-label="Toggle media details"]').click() + + const grid = page.locator('[data-slot="media-info-grid"]') + await expect(grid).toBeVisible() + await expect(grid).toContainText('Codec') + await expect(grid).toContainText('pcm_s16le') + // The exact container lives in the grid instead of the summary. + await expect(grid).toContainText('Container') + await expect(grid).toContainText('WAV') + await expect(grid).toContainText('Bitrate') + await expect(grid).toContainText('1536 kbps') +}) + +// Embedded tags render in their own group in the expanded panel, and embedded +// cover art becomes a blurred ambient background (not a thumbnail). +test('embedded tags render in the expanded panel and album art blurs the background', async ({ loadApp }) => { + const page = await loadApp(audioConfig) + await expect(page.locator('[data-slot="album-art"]')).toHaveCount(0) + + const art = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + await page.evaluate((cover) => { + window.jetplayMediaInfo?.({ + codec: 'flac', + container: 'flac', + sampleRateHz: 44100, + channels: 2, + channelLabel: 'stereo', + tags: [ + { label: 'Title', value: 'Aerodynamic' }, + { label: 'Artist', value: 'Daft Punk' }, + { label: 'Album', value: 'Discovery' }, + ], + albumArt: cover, + }) + }, art) + + // Cover art appears as the ambient blurred background layer. + await expect(page.locator('[data-slot="album-art"]')).toBeVisible() + + // Tags live in their own group, revealed on expand. + await expect(page.locator('[data-slot="media-info-tags"]')).toHaveCount(0) + await page.locator('[aria-label="Toggle media details"]').click() + const tagsPanel = page.locator('[data-slot="media-info-tags"]') + await expect(tagsPanel).toBeVisible() + await expect(tagsPanel).toContainText('Title') + await expect(tagsPanel).toContainText('Aerodynamic') + await expect(tagsPanel).toContainText('Daft Punk') +}) + +// Regression: the player container's Space shortcut must not swallow the +// toggle button's native activation. Space on the focused toggle expands the +// inspector and must NOT start playback. +test('space activates the focused media-details toggle, not playback', async ({ loadApp }) => { + const page = await loadApp(audioConfig) + await page.evaluate(() => + window.jetplayMediaInfo?.({ codec: 'pcm_s16le', container: 'wav', sampleRateHz: 48000, channels: 2, channelLabel: 'stereo' }), + ) + const toggle = page.locator('[aria-label="Toggle media details"]') + await toggle.focus() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await page.keyboard.press('Space') + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(page.locator('[data-slot="media-info-grid"]')).toBeVisible() + await expect(page.locator('audio')).toHaveJSProperty('paused', true) +}) + +// A push that arrived before the page defined the handler is stashed on window +// and must still be picked up on mount (same guard as the waveform). +test('media info buffered before load is picked up', async ({ page }) => { + await page.addInitScript(() => { + ;(window as any).jetplay = { + state: 'ready', + fileName: 'x.wav', + fileExtension: 'wav', + mediaUrl: '/assets/does-not-exist.mp3', + isVideo: false, + } + ;(window as any).__jetplayMediaInfo = { container: 'wav', sampleRateHz: 44100, channels: 1, channelLabel: 'mono' } + }) + await page.goto('/') + const summary = page.locator('[data-slot="media-info-summary"]') + await expect(summary).toBeVisible() + await expect(summary).toContainText('44.1 kHz') +}) + +// Muting must not lose the saved level — unmuting returns to where it was. +test('muting then unmuting preserves the volume level', async ({ loadApp }) => { + const page = await loadApp(audioConfig) + const muteBtn = page.locator('button[aria-label="Toggle mute"]') + + await expect(page.getByText('100%')).toBeVisible() + await muteBtn.click() + await expect(page.getByText('0%')).toBeVisible() + await muteBtn.click() + await expect(page.getByText('100%')).toBeVisible() +}) + test('play button toggles playback', async ({ loadApp }) => { const page = await loadApp(audioConfig) - const playBtn = page.locator('button.rounded-full') + const playBtn = page.locator('button[aria-label="Play"], button[aria-label="Pause"]') // Initially paused — Play icon visible await expect(playBtn).toBeVisible() @@ -113,11 +294,11 @@ test('seek bar click seeks to position', async ({ loadApp }) => { return el && el.duration > 0 }) - const seekBar = page.locator('.group.h-5') + const seekBar = page.locator('[data-slot="audio-player-progress"]') const box = await seekBar.boundingBox() - if (!box) throw new Error('SeekBar not visible') + if (!box) throw new Error('Progress bar not visible') - // Click at ~50% of seek bar + // Click at ~50% of the progress bar (the waveform above is drag-to-scrub) await seekBar.click({ position: { x: box.width * 0.5, y: box.height / 2 } }) const currentTime = await audio.evaluate((el: HTMLAudioElement) => el.currentTime) diff --git a/ui/tests/states.spec.ts b/ui/tests/states.spec.ts index a5c5e066..34319276 100644 --- a/ui/tests/states.spec.ts +++ b/ui/tests/states.spec.ts @@ -12,7 +12,7 @@ test('audio player renders in ready state', async ({ loadApp }) => { await expect(page.locator('audio')).toBeAttached() await expect(page.getByText('sintel.ogg')).toBeVisible() // Play button (the round one with Play icon) should be visible - await expect(page.locator('button.rounded-full')).toBeVisible() + await expect(page.locator('button[aria-label="Play"], button[aria-label="Pause"]')).toBeVisible() }) test('video player renders in ready state', async ({ loadApp }) => { @@ -93,4 +93,26 @@ test('extension badge displays in audio player', async ({ loadApp }) => { }) await expect(page.getByText('mp3', { exact: true })).toBeVisible() +}) + +// Regression: a fast transcode can call jetplayReady before the app has mounted, +// so the handler call is a no-op and it sticks on "Converting…" at 0%. The IDE +// stashes the ready URL on window; the app must seed from it on mount. +test('a media-ready buffered before mount leaves the converting screen', async ({ page }) => { + await page.addInitScript(() => { + ;(window as any).jetplay = { state: 'loading', fileName: 'clip.mp4', fileExtension: 'mp4', isVideo: true } + ;(window as any).__jetplayReadyUrl = '/assets/sintel.webm' + }) + await page.goto('/') + await expect(page.getByText('Converting for playback…')).toHaveCount(0) + await expect(page.locator('video')).toBeAttached() +}) + +test('an error buffered before mount shows the error state', async ({ page }) => { + await page.addInitScript(() => { + ;(window as any).jetplay = { state: 'loading', fileName: 'x.mp4', fileExtension: 'mp4' } + ;(window as any).__jetplayError = 'Codec not supported' + }) + await page.goto('/') + await expect(page.getByText('Codec not supported')).toBeVisible() }) \ No newline at end of file diff --git a/ui/tests/video-player.spec.ts b/ui/tests/video-player.spec.ts index a4c27ad2..e66e489d 100644 --- a/ui/tests/video-player.spec.ts +++ b/ui/tests/video-player.spec.ts @@ -25,8 +25,7 @@ test('play/pause button in overlay controls', async ({ loadApp }) => { const page = await loadApp(videoConfig) const video = page.locator('video') - // The overlay play button is inside the controls bar (not the round one like audio) - const playBtn = page.locator('.bg-gradient-to-t button').first() + const playBtn = page.locator('button[aria-label="Play"], button[aria-label="Pause"]') await expect(playBtn).toBeVisible() await playBtn.click() @@ -36,6 +35,74 @@ test('play/pause button in overlay controls', async ({ loadApp }) => { await expect(video).toHaveJSProperty('paused', true) }) +test('modern transport controls are present', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + for (const label of [ + 'Back 10 seconds', + 'Previous frame', + 'Next frame', + 'Forward 10 seconds', + 'Playback speed', + ]) { + await expect(page.locator(`button[aria-label="${label}"]`)).toBeVisible() + } +}) + +test('forward 10s button advances time', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + const video = page.locator('video') + await page.waitForFunction(() => { + const el = document.querySelector('video') + return el && el.duration > 0 + }) + const before = await video.evaluate((el: HTMLVideoElement) => el.currentTime) + await page.locator('button[aria-label="Forward 10 seconds"]').click() + const after = await video.evaluate((el: HTMLVideoElement) => el.currentTime) + expect(after).toBeGreaterThan(before) +}) + +test('speed dropdown changes playback rate', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + await page.locator('button[aria-label="Playback speed"]').click() + await page.getByText('1.5x', { exact: true }).click() + await expect(page.locator('video')).toHaveJSProperty('playbackRate', 1.5) +}) + +test('media-info toggle reveals the video metadata panel', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + // No inspector until the IDE pushes metadata. + await expect(page.locator('button[aria-label="Toggle media details"]')).toHaveCount(0) + + await page.evaluate(() => + window.jetplayMediaInfo?.({ + container: 'webm', + durationMs: 5000, + sizeBytes: 583000, + width: 854, + height: 480, + frameRate: 24, + videoCodec: 'vp9', + pixelFormat: 'yuv420p', + codec: 'opus', + sampleRateHz: 48000, + channels: 2, + channelLabel: 'stereo', + }), + ) + + const toggle = page.locator('button[aria-label="Toggle media details"]') + await expect(toggle).toBeVisible() + await expect(page.locator('[data-slot="media-info-panel"]')).toHaveCount(0) + + await toggle.click() + const panel = page.locator('[data-slot="media-info-panel"]') + await expect(panel).toBeVisible() + await expect(panel).toContainText('Resolution') + await expect(panel).toContainText('854×480') + await expect(panel).toContainText('vp9') + await expect(panel).toContainText('opus') // audio stream too +}) + test('space key toggles playback', async ({ loadApp }) => { const page = await loadApp(videoConfig) const video = page.locator('video') @@ -100,6 +167,73 @@ test('mouse movement shows controls when hidden', async ({ loadApp }) => { await expect(controls).not.toHaveClass(/opacity-0/) }) +// Regression: the seek Slider's onValueChange fires on every programmatic value +// change (each timeupdate), so without an interaction guard the player re-seeks +// itself every frame and stutters. A freely-playing video must not self-seek. +test('playing does not self-seek (no stutter feedback loop)', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + const video = page.locator('video') + await page.waitForFunction(() => { + const el = document.querySelector('video') + return el && el.duration > 0 + }) + await page.evaluate(() => { + const el = document.querySelector('video')! + ;(window as unknown as { __seeks: number }).__seeks = 0 + el.addEventListener('seeking', () => { + ;(window as unknown as { __seeks: number }).__seeks++ + }) + }) + await page.locator('button[aria-label="Play"]').click() + await page.waitForTimeout(1200) + + const seeks = await page.evaluate(() => (window as unknown as { __seeks: number }).__seeks) + expect(seeks).toBeLessThanOrEqual(1) + expect(await video.evaluate((el: HTMLVideoElement) => el.currentTime)).toBeGreaterThan(0.3) +}) + +// Regression: bits-ui fires onValueChange in the thumb's bubble phase before a +// Root-level keydown could open the guard, so arrow nudges on a focused seek +// thumb were swallowed. A capture-phase handler must open the guard in time. +test('keyboard arrows on the focused seek thumb actually seek', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + const video = page.locator('video') + await page.waitForFunction(() => { + const el = document.querySelector('video') + return el && el.duration > 0 + }) + await page.locator('[data-seek-slider] [role="slider"]').focus() + const before = await video.evaluate((el: HTMLVideoElement) => el.currentTime) + for (let i = 0; i < 12; i++) await page.keyboard.press('ArrowRight') + const after = await video.evaluate((el: HTMLVideoElement) => el.currentTime) + expect(after).toBeGreaterThan(before) +}) + +// Regression: bits-ui releases the pointer on `document`, so an element-bound +// onpointerup misses an off-track release — leaving the guard stuck open and the +// video stuck paused (the stutter loop returns on replay). A window listener +// must always close the scrub and resume. +test('releasing a scrub off the track resumes playback', async ({ loadApp }) => { + const page = await loadApp(videoConfig) + const video = page.locator('video') + await page.waitForFunction(() => { + const el = document.querySelector('video') + return el && el.duration > 0 + }) + await page.locator('button[aria-label="Play"]').click() + await expect(video).toHaveJSProperty('paused', false) + + const box = await page.locator('[data-seek-slider]').boundingBox() + if (!box) throw new Error('seek bar not visible') + await page.mouse.move(box.x + box.width * 0.3, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.6, box.y - 120) // drag off the thin track + await page.mouse.up() // release OFF the slider element + + // Guard closed via the window listener → playback resumes (not stuck paused). + await expect(video).toHaveJSProperty('paused', false) +}) + test('time display shows formatted time', async ({ loadApp }) => { const page = await loadApp(videoConfig) diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index acfd2e5b..1e291ea6 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -15,7 +15,12 @@ */ "allowJs": true, "checkJs": true, - "moduleDetection": "force" + "moduleDetection": "force", + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 1ffef600..c2baf424 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -3,5 +3,12 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } - ] + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } + } } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 51f7c7a3..027c2f3b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,16 @@ import { defineConfig } from 'vite' +import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import { svelte } from '@sveltejs/vite-plugin-svelte' import { viteSingleFile } from 'vite-plugin-singlefile' export default defineConfig({ plugins: [tailwindcss(), svelte(), viteSingleFile()], + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), + }, + }, build: { outDir: '../src/main/resources/player', emptyOutDir: true,