| 1 | #!/usr/bin/env bash |
| 2 | # Terminal video player — mpv only (tct/kitty vo) with progress bar. |
| 3 | |
| 4 | BRT_YTDLP_FORMAT="${BRT_YTDLP_FORMAT:-best[ext=mp4][vcodec^=avc1][height<=720]/best[ext=mp4][height<=720]/best[height<=720]/best[height<=480]}" |
| 5 | |
| 6 | brt_video_tty() { |
| 7 | if [[ -w /dev/tty ]]; then |
| 8 | printf '/dev/tty' |
| 9 | else |
| 10 | printf '/dev/stdout' |
| 11 | fi |
| 12 | } |
| 13 | |
| 14 | brt_video_term_prepare() { |
| 15 | local tty rows |
| 16 | tty=$(brt_video_tty) |
| 17 | rows=$(tput lines 2>/dev/null || echo 24) |
| 18 | rows=$(( rows < 10 ? 24 : rows )) |
| 19 | { |
| 20 | # Alt-screen wheel → cursor keys, not scrollback (prevents tct corruption). |
| 21 | printf '\033[?1007h' |
| 22 | # Lock scroll region to the viewport so trailing newlines can't shift the canvas. |
| 23 | printf '\033[1;%dr' "$rows" |
| 24 | } >"$tty" 2>/dev/null || true |
| 25 | } |
| 26 | |
| 27 | # Reinitialize the terminal after playback (mpv tct often skips cleanup on SIGINT). |
| 28 | brt_video_term_restore() { |
| 29 | local tty vo="${BRT_VIDEO_VO:-}" rows cols |
| 30 | [[ -w /dev/tty ]] || return 0 |
| 31 | tty=$(brt_video_tty) |
| 32 | { |
| 33 | [[ "$vo" == "kitty" ]] && printf '\033_Ga=d,a=A\033\\' |
| 34 | printf '\033[?1007l\033[r\033[?1049l\033[?47l' |
| 35 | } >"$tty" 2>/dev/null || true |
| 36 | unset COLUMNS LINES |
| 37 | if read -r rows cols _ < <(stty size 2>/dev/null <"$tty"); then |
| 38 | stty rows "$rows" cols "$cols" <"$tty" 2>/dev/null || true |
| 39 | fi |
| 40 | reset >"$tty" 2>&1 <"$tty" || stty sane <"$tty" 2>/dev/null || true |
| 41 | kill -WINCH $$ 2>/dev/null || true |
| 42 | } |
| 43 | |
| 44 | brt_video_on_int() { |
| 45 | brt_video_term_restore |
| 46 | } |
| 47 | |
| 48 | brt_video_on_exit() { |
| 49 | brt_video_term_restore |
| 50 | unset BRT_VIDEO_VO |
| 51 | } |
| 52 | |
| 53 | brt_video_input_conf() { |
| 54 | local f |
| 55 | f=$(mktemp "${TMPDIR:-/tmp}/brt-mpv-input.XXXXXX") |
| 56 | cat > "$f" << 'EOF' |
| 57 | WHEEL_UP ignore |
| 58 | WHEEL_DOWN ignore |
| 59 | WHEEL_LEFT ignore |
| 60 | WHEEL_RIGHT ignore |
| 61 | EOF |
| 62 | printf '%s' "$f" |
| 63 | } |
| 64 | |
| 65 | brt_video_is_url() { |
| 66 | [[ "${1:-}" =~ ^https?:// ]] |
| 67 | } |
| 68 | |
| 69 | brt_video_resolve_local_path() { |
| 70 | local p="$1" dir base |
| 71 | case "$p" in |
| 72 | ~/*) p="${HOME}/${p#~/}" ;; |
| 73 | ~) p="$HOME" ;; |
| 74 | esac |
| 75 | if [[ ! -f "$p" ]]; then |
| 76 | return 1 |
| 77 | fi |
| 78 | if command -v realpath &>/dev/null; then |
| 79 | realpath "$p" |
| 80 | return 0 |
| 81 | fi |
| 82 | dir=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1 |
| 83 | base=$(basename "$p") |
| 84 | printf '%s/%s' "$dir" "$base" |
| 85 | } |
| 86 | |
| 87 | brt_video_is_youtube() { |
| 88 | [[ "${1:-}" =~ (youtube\.com|youtu\.be|youtube-nocookie\.com) ]] |
| 89 | } |
| 90 | |
| 91 | brt_video_ytdlp() { |
| 92 | if command -v yt-dlp &>/dev/null; then |
| 93 | printf '%s' "yt-dlp" |
| 94 | return 0 |
| 95 | fi |
| 96 | if command -v youtube-dl &>/dev/null; then |
| 97 | printf '%s' "youtube-dl" |
| 98 | return 0 |
| 99 | fi |
| 100 | return 1 |
| 101 | } |
| 102 | |
| 103 | brt_video_resolve_youtube() { |
| 104 | local url="$1" ytdlp stream |
| 105 | ytdlp=$(brt_video_ytdlp) || return 1 |
| 106 | stream=$("$ytdlp" --no-playlist -f "${BRT_YTDLP_FORMAT}" -g "$url" 2>/dev/null | head -1) |
| 107 | if [[ -z "$stream" ]]; then |
| 108 | stream=$("$ytdlp" --no-playlist -f best -g "$url" 2>/dev/null | head -1) |
| 109 | fi |
| 110 | [[ -n "$stream" ]] || return 1 |
| 111 | printf '%s' "$stream" |
| 112 | } |
| 113 | |
| 114 | brt_video_vo_supported() { |
| 115 | local vo="$1" help |
| 116 | help=$(mpv --vo=help 2>/dev/null || true) |
| 117 | [[ -n "$help" ]] && grep -qE "(^|[[:space:]])${vo}([[:space:]]|$)" <<< "$help" |
| 118 | } |
| 119 | |
| 120 | brt_video_pick_vo() { |
| 121 | local force="${BRT_VIDEO_VO_FORCE:-}" vo |
| 122 | |
| 123 | if [[ -n "$force" ]] && brt_video_vo_supported "$force"; then |
| 124 | printf '%s' "$force" |
| 125 | return 0 |
| 126 | fi |
| 127 | |
| 128 | if [[ "${TERM_PROGRAM:-}" == *[Gg]hostty* ]] && brt_video_vo_supported tct; then |
| 129 | printf '%s' "tct" |
| 130 | return 0 |
| 131 | fi |
| 132 | |
| 133 | for vo in tct kitty; do |
| 134 | if brt_video_vo_supported "$vo"; then |
| 135 | printf '%s' "$vo" |
| 136 | return 0 |
| 137 | fi |
| 138 | done |
| 139 | return 1 |
| 140 | } |
| 141 | |
| 142 | brt_video_tct_algo() { |
| 143 | local algo="${BRT_TCT_ALGO:-half-blocks}" |
| 144 | case "$algo" in |
| 145 | plain|half-blocks) printf '%s' "$algo" ;; |
| 146 | *) printf '%s' "half-blocks" ;; |
| 147 | esac |
| 148 | } |
| 149 | |
| 150 | brt_video_size() { |
| 151 | local cols rows |
| 152 | cols=0 |
| 153 | rows=0 |
| 154 | |
| 155 | if [[ -r /dev/tty ]]; then |
| 156 | read -r rows cols _ < <(stty size 2>/dev/null </dev/tty) || true |
| 157 | fi |
| 158 | if ! [[ "$cols" =~ ^[0-9]+$ && "$rows" =~ ^[0-9]+$ && "$cols" -gt 0 && "$rows" -gt 0 ]]; then |
| 159 | cols=$(tput cols 2>/dev/null || echo 80) |
| 160 | rows=$(tput lines 2>/dev/null || echo 24) |
| 161 | fi |
| 162 | |
| 163 | cols=$(( cols < 10 ? 80 : cols )) |
| 164 | rows=$(( rows < 10 ? 24 : rows )) |
| 165 | printf '%s %s\n' "$cols" "$rows" |
| 166 | } |
| 167 | |
| 168 | brt_video_user_ok() { |
| 169 | local rc="${1:-0}" |
| 170 | (( rc == 0 || rc == 130 || rc == 143 )) |
| 171 | } |
| 172 | |
| 173 | brt_video_play() { |
| 174 | local source="$1" |
| 175 | local audio="$2" |
| 176 | local cols rows vo algo rc=0 log input_conf |
| 177 | local -a mpv_args=() |
| 178 | local -a network_args=() |
| 179 | |
| 180 | read -r cols rows < <(brt_video_size) |
| 181 | vo=$(brt_video_pick_vo) || return 127 |
| 182 | export BRT_VIDEO_VO="$vo" |
| 183 | algo=$(brt_video_tct_algo) |
| 184 | log=$(mktemp "${TMPDIR:-/tmp}/brt-mpv.XXXXXX") |
| 185 | input_conf=$(brt_video_input_conf) |
| 186 | |
| 187 | brt_video_term_prepare |
| 188 | |
| 189 | mpv_args=( |
| 190 | --quiet |
| 191 | --terminal=no |
| 192 | --input-terminal=yes |
| 193 | --input-conf="$input_conf" |
| 194 | --msg-level=all=no |
| 195 | --log-file="$log" |
| 196 | --force-window=no |
| 197 | --keep-open=no |
| 198 | --term-osd=force |
| 199 | --term-osd-bar=yes |
| 200 | --term-status-msg='${time-pos}/${duration} (${percent-pos}%)' |
| 201 | --vo="$vo" |
| 202 | --keepaspect=no |
| 203 | --panscan=1.0 |
| 204 | --video-unscaled=no |
| 205 | --autosync=30 |
| 206 | --video-sync=display-resample |
| 207 | --cache=yes |
| 208 | --cache-pause=no |
| 209 | --demuxer-readahead-secs=30 |
| 210 | --cache-secs=90 |
| 211 | --profile=sw-fast |
| 212 | ) |
| 213 | |
| 214 | case "$vo" in |
| 215 | tct) |
| 216 | mpv_args+=( |
| 217 | --vo-tct-width="$cols" |
| 218 | --vo-tct-height="$rows" |
| 219 | --vo-tct-algo="$algo" |
| 220 | --vo-tct-buffering=frame |
| 221 | --monitorpixelaspect=0.5 |
| 222 | ) |
| 223 | ;; |
| 224 | kitty) |
| 225 | mpv_args+=( |
| 226 | --vo-kitty-alt-screen=yes |
| 227 | --vo-kitty-config-clear=yes |
| 228 | --vo-kitty-cols="$cols" |
| 229 | --vo-kitty-rows="$rows" |
| 230 | ) |
| 231 | ;; |
| 232 | esac |
| 233 | |
| 234 | if brt_video_is_youtube "$source"; then |
| 235 | local stream |
| 236 | if stream=$(brt_video_resolve_youtube "$source"); then |
| 237 | source="$stream" |
| 238 | network_args=( |
| 239 | --network-timeout=120 |
| 240 | --stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5,fflags=+discardcorrupt |
| 241 | ) |
| 242 | else |
| 243 | network_args=(--ytdl=yes --ytdl-format="${BRT_YTDLP_FORMAT}" --network-timeout=120) |
| 244 | fi |
| 245 | elif brt_video_is_url "$source"; then |
| 246 | network_args=( |
| 247 | --network-timeout=60 |
| 248 | --stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5,fflags=+discardcorrupt |
| 249 | ) |
| 250 | else |
| 251 | local resolved |
| 252 | if ! resolved=$(brt_video_resolve_local_path "$source"); then |
| 253 | brt_error "Not found: ${source}" |
| 254 | rm -f "$log" "$input_conf" |
| 255 | return 1 |
| 256 | fi |
| 257 | source="$resolved" |
| 258 | network_args=(--no-ytdl) |
| 259 | fi |
| 260 | |
| 261 | if ((${#network_args[@]} > 0)); then |
| 262 | mpv_args+=("${network_args[@]}") |
| 263 | fi |
| 264 | (( audio )) || mpv_args+=(--no-audio) |
| 265 | |
| 266 | # Foreground mpv keeps keyboard input; restore runs on INT trap and again after exit. |
| 267 | mpv "${mpv_args[@]}" "$source" 2>/dev/null |
| 268 | rc=$? |
| 269 | if ! brt_video_user_ok "$rc" && [[ -s "$log" ]]; then |
| 270 | tail -5 "$log" >&2 |
| 271 | fi |
| 272 | rm -f "$log" "$input_conf" |
| 273 | brt_video_term_restore |
| 274 | return "$rc" |
| 275 | } |
| 276 | |
| 277 | brt_video() { |
| 278 | local source="${1:-}" |
| 279 | local audio=1 rc |
| 280 | |
| 281 | if [[ -z "$source" ]]; then |
| 282 | brt_error "Usage: brt -video <file|url> [--no-audio]" |
| 283 | brt_info "Terminal video via mpv — space pause, q quit, arrows seek" |
| 284 | brt_info "Press q to quit — terminal restores automatically on exit" |
| 285 | brt_info "Supports local files, HTTP URLs, and YouTube links" |
| 286 | exit 1 |
| 287 | fi |
| 288 | |
| 289 | shift |
| 290 | while [[ $# -gt 0 ]]; do |
| 291 | case "$1" in |
| 292 | --no-audio) audio=0; shift ;; |
| 293 | -x|-y|-w|--width|-fps) |
| 294 | brt_warn "Deprecated option ignored: $1 (video fills the terminal)" |
| 295 | shift 2 |
| 296 | ;; |
| 297 | --tiny-font|--no-tiny-font) |
| 298 | brt_warn "Deprecated option ignored: $1" |
| 299 | shift |
| 300 | ;; |
| 301 | *) |
| 302 | brt_error "Unknown option: $1" |
| 303 | brt_info "Usage: brt -video <file|url> [--no-audio]" |
| 304 | exit 1 |
| 305 | ;; |
| 306 | esac |
| 307 | done |
| 308 | |
| 309 | command -v mpv &>/dev/null || { |
| 310 | brt_error "mpv is required for video playback" |
| 311 | brt_info "Install: brew install mpv" |
| 312 | exit 1 |
| 313 | } |
| 314 | |
| 315 | if brt_video_is_youtube "$source" && ! brt_video_ytdlp &>/dev/null; then |
| 316 | brt_warn "Install yt-dlp for reliable YouTube: brew install yt-dlp" |
| 317 | fi |
| 318 | |
| 319 | trap 'brt_video_on_int' INT |
| 320 | trap 'brt_video_on_exit' EXIT |
| 321 | |
| 322 | set +e |
| 323 | brt_video_play "$source" "$audio" |
| 324 | rc=$? |
| 325 | set -e |
| 326 | |
| 327 | if brt_video_user_ok "$rc"; then |
| 328 | return 0 |
| 329 | fi |
| 330 | brt_error "Playback failed (exit ${rc})" |
| 331 | return "$rc" |
| 332 | } |
| 333 | |