feat: extended preview for PDF, audio, video via external utilities

Add rich preview support for three new file categories by leveraging
external CLI tools with graceful fallback when tools are missing.

- PDF: text extraction via pdftotext, page count via pdfinfo
- Audio: metadata via ffprobe (duration, bitrate, codec, sample rate, channels)
- Video: metadata via ffprobe (duration, bitrate, video/audio codec, resolution)
- New PreviewKind constants: PDF, Audio, Video
- New Metadata fields for extended preview data
- New extension maps and Category() entries for pdf/audio/video
- Icons: PDF (󰷉), audio (󰋋), video (󰋲) in preview header

Closes #5
This commit is contained in:
vrubelroman 2026-04-27 19:25:03 +03:00
parent 813c40a41e
commit a483267bd9
5 changed files with 833 additions and 8 deletions

View file

@ -36,23 +36,24 @@ go build -o vcom ./cmd/vcom
Run directly from the flake:
```bash
nix run github:vrubelroman/vcom?ref=v0.1.9
nix run github:vrubelroman/vcom?ref=v0.2.0
```
Install into user profile:
```bash
nix profile add github:vrubelroman/vcom?ref=v0.1.9
nix profile add github:vrubelroman/vcom?ref=v0.2.0
```
The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works in non-`kitty` terminals out of the box.
### Debian / Ubuntu
Download the release `.deb` for `v0.1.9`, then install:
Download and install the latest release:
```bash
sudo apt install ./vcom_0.1.9_amd64.deb
curl -sL https://github.com/vrubelroman/vcom/releases/download/v0.2.0/vcom_0.2.0_amd64.deb -o /tmp/vcom_0.2.0_amd64.deb
sudo apt install /tmp/vcom_0.2.0_amd64.deb
```
The Debian package declares `ueberzug` (or `ueberzugpp` where available) as a dependency for image preview outside `kitty`.
@ -180,7 +181,7 @@ Built-in themes (use `T` to cycle or set `ui.theme` in config):
## Releases
Pushing a tag like `v0.1.9` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
Pushing a tag like `v0.2.0` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
- runs tests
- vendors Go modules
@ -190,9 +191,9 @@ Pushing a tag like `v0.1.9` triggers GitHub Actions release workflow (`.github/w
Release artifacts:
- `vcom-v0.1.9-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.1.9_amd64.deb`
- `vcom-v0.1.9-checksums.txt`
- `vcom-v0.2.0-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.2.0_amd64.deb`
- `vcom-v0.2.0-checksums.txt`
## Notes

View file

@ -56,6 +56,17 @@ var (
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
"avif": {}, "heic": {}, "heif": {}, "tiff": {}, "tif": {},
}
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
archiveExtensions = map[string]struct{}{
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
"zst": {}, "lz": {}, "lz4": {}, "lzma": {},
@ -113,6 +124,12 @@ func (e Entry) Category() string {
return "image"
case hasExt(textExtensions, e.Extension):
return "text"
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
case hasExt(archiveExtensions, e.Extension):
return "archive"
case hasExt(textFilenames, strings.ToLower(e.Name)):

View file

@ -2,6 +2,7 @@ package vfs
import (
"bytes"
"encoding/json"
"fmt"
"image"
_ "image/gif"
@ -9,6 +10,7 @@ import (
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
@ -30,6 +32,9 @@ const (
PreviewKindDirectory PreviewKind = "directory"
PreviewKindText PreviewKind = "text"
PreviewKindImage PreviewKind = "image"
PreviewKindPDF PreviewKind = "pdf"
PreviewKindAudio PreviewKind = "audio"
PreviewKindVideo PreviewKind = "video"
PreviewKindBinary PreviewKind = "binary"
PreviewKindError PreviewKind = "error"
)
@ -45,6 +50,16 @@ type Metadata struct {
ImageFormat string
ImageSize string
Extension string
// Extended preview metadata
Duration string
Bitrate string
AudioCodec string
VideoCodec string
SampleRate string
Channels string
PageCount string
Dimensions string
}
type Preview struct {
@ -130,6 +145,17 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview {
return preview
}
// Extended preview for PDF, audio, video via external utilities
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
if IsBinarySample(data) {
preview.Kind = PreviewKindBinary
preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type."
@ -391,6 +417,260 @@ func renderImageInlinePreview(path string, width int, height int) string {
return ""
}
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if int64(len(text)) > options.MaxPreviewBytes {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line[5:], ":"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
func detectImage(data []byte) (string, string, bool) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {

View file

@ -2726,6 +2726,30 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int, useNerd
if meta.ImageFormat != "" {
rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
}
if meta.Duration != "" {
rightRows = append(rightRows, fmt.Sprintf("duration: %s", meta.Duration))
}
if meta.Bitrate != "" {
rightRows = append(rightRows, fmt.Sprintf("bitrate: %s", meta.Bitrate))
}
if meta.AudioCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("audio: %s", meta.AudioCodec))
}
if meta.VideoCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("video: %s", meta.VideoCodec))
}
if meta.Dimensions != "" {
rightRows = append(rightRows, fmt.Sprintf("resolution: %s", meta.Dimensions))
}
if meta.SampleRate != "" {
rightRows = append(rightRows, fmt.Sprintf("rate: %s", meta.SampleRate))
}
if meta.Channels != "" {
rightRows = append(rightRows, fmt.Sprintf("channels: %s", meta.Channels))
}
if meta.PageCount != "" {
rightRows = append(rightRows, fmt.Sprintf("pages: %s", meta.PageCount))
}
leftWidth := max(innerWidth/2, 18)
if leftWidth > innerWidth {
@ -3550,6 +3574,12 @@ func previewIcon(preview vfs.Preview) string {
return "󰋩"
case vfs.PreviewKindText:
return "󰈙"
case vfs.PreviewKindPDF:
return "󰷉"
case vfs.PreviewKindAudio:
return "󰋋"
case vfs.PreviewKindVideo:
return "󰋲"
case vfs.PreviewKindBinary:
return "󰈔"
case vfs.PreviewKindError:

View file

@ -0,0 +1,497 @@
# Extended Preview — PDF, Audio, Video via External Utilities
## Overview
Add rich preview support for three new file categories by leveraging external CLI tools. If a tool is not installed, the file falls back to the existing binary-file display.
```mermaid
flowchart TD
A[BuildPreview entry] --> B{Is directory?}
B -->|Yes| C[buildDirectoryPreview]
B -->|No| D[Open file, read header]
D --> E{Detect image?}
E -->|Yes| F[PreviewKindImage]
E -->|No| G{Detect PDF ext?}
G -->|Yes| H{pdftotext available?}
H -->|Yes| I[PreviewKindPDF - extract text]
H -->|No| J[Fallback to binary]
G -->|No| K{Detect audio ext?}
K -->|Yes| L{ffprobe available?}
L -->|Yes| M[PreviewKindAudio - show metadata]
L -->|No| N[Fallback to binary]
K -->|No| O{Detect video ext?}
O -->|Yes| P{ffprobe available?}
P -->|Yes| Q[PreviewKindVideo - show metadata]
P -->|No| R[Fallback to binary]
O -->|No| S[Is binary sample?]
S -->|Yes| T[PreviewKindBinary]
S -->|No| U[PreviewKindText + syntax highlight]
```
## 1. New PreviewKind Constants
File: [`internal/fs/preview.go`](internal/fs/preview.go) (around line 28-35)
Add three new constants to the `PreviewKind` type:
- `PreviewKindPDF PreviewKind = "pdf"`
- `PreviewKindAudio PreviewKind = "audio"`
- `PreviewKindVideo PreviewKind = "video"`
## 2. Extended Metadata Struct
File: [`internal/fs/preview.go`](internal/fs/preview.go) — `Metadata` struct (line 37-48)
Add new optional fields:
```go
type Metadata struct {
// ... existing fields ...
// Extended preview metadata
Duration string // audio/video duration (e.g. "3:42")
Bitrate string // audio/video bitrate (e.g. "320 kbps")
AudioCodec string // audio codec (e.g. "aac", "mp3")
VideoCodec string // video codec (e.g. "h264", "vp9")
SampleRate string // audio sample rate (e.g. "44100 Hz")
Channels string // audio channels (e.g. "stereo")
PageCount string // PDF page count
Dimensions string // video dimensions (e.g. "1920x1080")
}
```
## 3. New Extension Maps
File: [`internal/fs/entry.go`](internal/fs/entry.go) (around line 55-63)
Add three new extension sets:
```go
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
```
## 4. New Categories in `Category()` Method
File: [`internal/fs/entry.go`](internal/fs/entry.go) — `Category()` method (line 102-123)
Add three new cases in the switch statement (before `default`):
```go
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
```
## 5. External Utility Detection
File: [`internal/fs/preview.go`](internal/fs/preview.go) — new helper functions
```go
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
```
Add `"os/exec"` to imports.
## 6. Modified `BuildPreview()` Flow
File: [`internal/fs/preview.go`](internal/fs/preview.go) — `BuildPreview()` (line 73)
Insert the new checks after the image detection block (after line 131) and before the `IsBinarySample()` check:
```go
// PDF preview via pdftotext
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
// Audio preview via ffprobe
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
// Video preview via ffprobe
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
```
This placement ensures:
- Images are still detected by magic bytes (works for any extension)
- PDF/audio/video get their custom handling
- Files that don't match any category fall through to the existing binary/text detection
## 7. New Preview Builder Functions
File: [`internal/fs/preview.go`](internal/fs/preview.go) — new functions
### 7a. `buildPDFPreview()`
```go
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
// Extract text
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if len(text) > int(options.MaxPreviewBytes) {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line, "Pages:"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
```
### 7b. `buildAudioPreview()`
```go
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
```
### 7c. `buildVideoPreview()`
```go
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
```
## 8. UI: Update `renderMetadata()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `renderMetadata()` (line 2714)
Add new metadata fields to the right column after the image info block:
```go
// After: if meta.ImageFormat != "" { ... }
if meta.Duration != "" {
rightRows = append(rightRows, fmt.Sprintf("duration: %s", meta.Duration))
}
if meta.Bitrate != "" {
rightRows = append(rightRows, fmt.Sprintf("bitrate: %s", meta.Bitrate))
}
if meta.AudioCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("audio: %s", meta.AudioCodec))
}
if meta.VideoCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("video: %s", meta.VideoCodec))
}
if meta.Dimensions != "" {
rightRows = append(rightRows, fmt.Sprintf("resolution: %s", meta.Dimensions))
}
if meta.SampleRate != "" {
rightRows = append(rightRows, fmt.Sprintf("rate: %s", meta.SampleRate))
}
if meta.Channels != "" {
rightRows = append(rightRows, fmt.Sprintf("channels: %s", meta.Channels))
}
if meta.PageCount != "" {
rightRows = append(rightRows, fmt.Sprintf("pages: %s", meta.PageCount))
}
```
## 9. UI: Update `previewIcon()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `previewIcon()` (line 3545)
Add new Nerd Font icons:
```go
case vfs.PreviewKindPDF:
return "󰷉" // nf-oct-file-pdf
case vfs.PreviewKindAudio:
return "󰋋" // nf-custom-audio-file
case vfs.PreviewKindVideo:
return "󰋲" // nf-custom-video-file
```
And ASCII fallback:
```go
case "pdf":
return "[P]"
case "audio":
return "[A]"
case "video":
return "[V]"
```
## 10. UI: Update `syncImageOverlay()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `syncImageOverlay()` (line 4225)
The overlay should only show for `PreviewKindImage`, which it already checks. No change needed — video files should NOT use the image overlay since we don't have video thumbnail support yet.
## 11. Files Changed Summary
| File | Changes |
|------|---------|
| [`internal/fs/entry.go`](internal/fs/entry.go) | Add `pdfExtensions`, `audioExtensions`, `videoExtensions` maps; update `Category()` method |
| [`internal/fs/preview.go`](internal/fs/preview.go) | Add `PreviewKindPDF/Audio/Video` constants; add fields to `Metadata`; add `findTool()`, `buildPDFPreview()`, `buildAudioPreview()`, `buildVideoPreview()`; modify `BuildPreview()` insertion point; add `"os/exec"`, `"encoding/json"` imports |
| [`internal/ui/model.go`](internal/ui/model.go) | Update `renderMetadata()` with new fields; update `previewIcon()` with new icons |
## 12. Edge Cases & Considerations
- **Missing external tool**: If `pdftotext` or `ffprobe` is not installed, the user sees a helpful message telling them which package to install, and the preview falls back to `PreviewKindBinary` (same as before).
- **Large PDFs**: Text extraction respects `MaxPreviewBytes` limit — truncated output is still highlighted.
- **Corrupt files**: `ffprobe` and `pdftotext` handle errors gracefully; any non-zero exit returns `PreviewKindError` with the error message.
- **Performance**: External processes are launched synchronously in the preview command (which already runs in a goroutine via `loadPreviewCmd()`), so the UI remains responsive.
- **No new dependencies**: All tools are invoked via `os/exec` (stdlib). No Go modules needed.
## 13. Test Plan
1. Open `test.pdf` — verify text is extracted and syntax-highlighted, page count shown in metadata
2. Open `test.mp3` — verify duration, bitrate, codec, sample rate, channels shown
3. Open `test.flac` — same as above
4. Open `test.mp4` — verify duration, bitrate, video codec, resolution, audio codec shown
5. Open PDF/audio/video on a system without `pdftotext`/`ffprobe` — verify fallback message
6. Verify `go build ./...` and `go vet ./...` pass