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:
parent
813c40a41e
commit
a483267bd9
5 changed files with 833 additions and 8 deletions
17
README.md
17
README.md
|
|
@ -36,23 +36,24 @@ go build -o vcom ./cmd/vcom
|
||||||
Run directly from the flake:
|
Run directly from the flake:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:vrubelroman/vcom?ref=v0.1.9
|
nix run github:vrubelroman/vcom?ref=v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Install into user profile:
|
Install into user profile:
|
||||||
|
|
||||||
```bash
|
```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.
|
The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works in non-`kitty` terminals out of the box.
|
||||||
|
|
||||||
### Debian / Ubuntu
|
### Debian / Ubuntu
|
||||||
|
|
||||||
Download the release `.deb` for `v0.1.9`, then install:
|
Download and install the latest release:
|
||||||
|
|
||||||
```bash
|
```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`.
|
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
|
## 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
|
- runs tests
|
||||||
- vendors Go modules
|
- vendors Go modules
|
||||||
|
|
@ -190,9 +191,9 @@ Pushing a tag like `v0.1.9` triggers GitHub Actions release workflow (`.github/w
|
||||||
|
|
||||||
Release artifacts:
|
Release artifacts:
|
||||||
|
|
||||||
- `vcom-v0.1.9-x86_64-unknown-linux-gnu.tar.gz`
|
- `vcom-v0.2.0-x86_64-unknown-linux-gnu.tar.gz`
|
||||||
- `vcom_0.1.9_amd64.deb`
|
- `vcom_0.2.0_amd64.deb`
|
||||||
- `vcom-v0.1.9-checksums.txt`
|
- `vcom-v0.2.0-checksums.txt`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,17 @@ var (
|
||||||
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
|
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
|
||||||
"avif": {}, "heic": {}, "heif": {}, "tiff": {}, "tif": {},
|
"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{}{
|
archiveExtensions = map[string]struct{}{
|
||||||
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
|
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
|
||||||
"zst": {}, "lz": {}, "lz4": {}, "lzma": {},
|
"zst": {}, "lz": {}, "lz4": {}, "lzma": {},
|
||||||
|
|
@ -113,6 +124,12 @@ func (e Entry) Category() string {
|
||||||
return "image"
|
return "image"
|
||||||
case hasExt(textExtensions, e.Extension):
|
case hasExt(textExtensions, e.Extension):
|
||||||
return "text"
|
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):
|
case hasExt(archiveExtensions, e.Extension):
|
||||||
return "archive"
|
return "archive"
|
||||||
case hasExt(textFilenames, strings.ToLower(e.Name)):
|
case hasExt(textFilenames, strings.ToLower(e.Name)):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package vfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -30,6 +32,9 @@ const (
|
||||||
PreviewKindDirectory PreviewKind = "directory"
|
PreviewKindDirectory PreviewKind = "directory"
|
||||||
PreviewKindText PreviewKind = "text"
|
PreviewKindText PreviewKind = "text"
|
||||||
PreviewKindImage PreviewKind = "image"
|
PreviewKindImage PreviewKind = "image"
|
||||||
|
PreviewKindPDF PreviewKind = "pdf"
|
||||||
|
PreviewKindAudio PreviewKind = "audio"
|
||||||
|
PreviewKindVideo PreviewKind = "video"
|
||||||
PreviewKindBinary PreviewKind = "binary"
|
PreviewKindBinary PreviewKind = "binary"
|
||||||
PreviewKindError PreviewKind = "error"
|
PreviewKindError PreviewKind = "error"
|
||||||
)
|
)
|
||||||
|
|
@ -45,6 +50,16 @@ type Metadata struct {
|
||||||
ImageFormat string
|
ImageFormat string
|
||||||
ImageSize string
|
ImageSize string
|
||||||
Extension 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 {
|
type Preview struct {
|
||||||
|
|
@ -130,6 +145,17 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview {
|
||||||
return 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) {
|
if IsBinarySample(data) {
|
||||||
preview.Kind = PreviewKindBinary
|
preview.Kind = PreviewKindBinary
|
||||||
preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type."
|
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 ""
|
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) {
|
func detectImage(data []byte) (string, string, bool) {
|
||||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2726,6 +2726,30 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int, useNerd
|
||||||
if meta.ImageFormat != "" {
|
if meta.ImageFormat != "" {
|
||||||
rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
|
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)
|
leftWidth := max(innerWidth/2, 18)
|
||||||
if leftWidth > innerWidth {
|
if leftWidth > innerWidth {
|
||||||
|
|
@ -3550,6 +3574,12 @@ func previewIcon(preview vfs.Preview) string {
|
||||||
return ""
|
return ""
|
||||||
case vfs.PreviewKindText:
|
case vfs.PreviewKindText:
|
||||||
return ""
|
return ""
|
||||||
|
case vfs.PreviewKindPDF:
|
||||||
|
return ""
|
||||||
|
case vfs.PreviewKindAudio:
|
||||||
|
return ""
|
||||||
|
case vfs.PreviewKindVideo:
|
||||||
|
return ""
|
||||||
case vfs.PreviewKindBinary:
|
case vfs.PreviewKindBinary:
|
||||||
return ""
|
return ""
|
||||||
case vfs.PreviewKindError:
|
case vfs.PreviewKindError:
|
||||||
|
|
|
||||||
497
plans/extended-preview-feature.md
Normal file
497
plans/extended-preview-feature.md
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue