termview 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termview/__init__.py +55 -0
- termview/audio.py +141 -0
- termview/cli.py +215 -0
- termview/controls.py +114 -0
- termview/detect.py +188 -0
- termview/loader.py +90 -0
- termview/renderers/__init__.py +37 -0
- termview/renderers/base.py +14 -0
- termview/renderers/block.py +185 -0
- termview/renderers/iterm2.py +34 -0
- termview/renderers/kitty.py +64 -0
- termview/renderers/sixel.py +88 -0
- termview/resize.py +120 -0
- termview/stream.py +410 -0
- termview/terminal.py +143 -0
- termview-0.1.0.dist-info/METADATA +148 -0
- termview-0.1.0.dist-info/RECORD +20 -0
- termview-0.1.0.dist-info/WHEEL +4 -0
- termview-0.1.0.dist-info/entry_points.txt +2 -0
- termview-0.1.0.dist-info/licenses/LICENSE +21 -0
termview/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
termview — view images, animations, and videos in the terminal.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
|
|
6
|
+
from termview import detect_renderer, get_renderer, fit_image, load_image
|
|
7
|
+
from termview import stream_video, stream_animation
|
|
8
|
+
|
|
9
|
+
Most users want the `tv` CLI command rather than the library API.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .audio import AudioPlayer, is_available as audio_available
|
|
13
|
+
from .controls import Key, read_key
|
|
14
|
+
from .detect import (
|
|
15
|
+
ColorDepth,
|
|
16
|
+
RendererType,
|
|
17
|
+
detect_color_depth,
|
|
18
|
+
detect_environment,
|
|
19
|
+
detect_renderer,
|
|
20
|
+
terminal_size,
|
|
21
|
+
)
|
|
22
|
+
from .loader import (
|
|
23
|
+
is_animated,
|
|
24
|
+
is_image,
|
|
25
|
+
is_video,
|
|
26
|
+
iter_frames,
|
|
27
|
+
load_image,
|
|
28
|
+
)
|
|
29
|
+
from .renderers import get_renderer
|
|
30
|
+
from .resize import fit_image
|
|
31
|
+
from .stream import stream_animation, stream_video
|
|
32
|
+
from .terminal import TerminalState
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AudioPlayer",
|
|
36
|
+
"ColorDepth",
|
|
37
|
+
"Key",
|
|
38
|
+
"RendererType",
|
|
39
|
+
"TerminalState",
|
|
40
|
+
"audio_available",
|
|
41
|
+
"detect_color_depth",
|
|
42
|
+
"detect_environment",
|
|
43
|
+
"detect_renderer",
|
|
44
|
+
"fit_image",
|
|
45
|
+
"get_renderer",
|
|
46
|
+
"is_animated",
|
|
47
|
+
"is_image",
|
|
48
|
+
"is_video",
|
|
49
|
+
"iter_frames",
|
|
50
|
+
"load_image",
|
|
51
|
+
"read_key",
|
|
52
|
+
"stream_animation",
|
|
53
|
+
"stream_video",
|
|
54
|
+
"terminal_size",
|
|
55
|
+
]
|
termview/audio.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audio playback for video files via an external subprocess.
|
|
3
|
+
|
|
4
|
+
We do *not* decode or mix audio in-process — that would require either PyAV
|
|
5
|
+
(50 MB+ C extension) or PyAudio + manual A/V sync, both of which are vastly
|
|
6
|
+
more complex than the actual problem. Instead we shell out to the first
|
|
7
|
+
available audio player on the system:
|
|
8
|
+
|
|
9
|
+
ffplay — cross-platform, ships with ffmpeg, handles every format
|
|
10
|
+
afplay — built into macOS, handles mp3/m4a/wav but not raw video
|
|
11
|
+
paplay — Linux PulseAudio, handles wav only
|
|
12
|
+
|
|
13
|
+
ffplay is the only one that decodes audio out of arbitrary video containers,
|
|
14
|
+
so it's the one we actively support. The others are documented fallbacks for
|
|
15
|
+
when the user has audio files (.mp3 etc.) rather than video.
|
|
16
|
+
|
|
17
|
+
Sync model
|
|
18
|
+
----------
|
|
19
|
+
We don't try to read the audio process's clock. Instead:
|
|
20
|
+
- audio subprocess is started at wall-clock T₀ and paces itself.
|
|
21
|
+
- the video render loop also derives its target frame time from T₀.
|
|
22
|
+
- both run off the same origin → drift is bounded by the audio player's
|
|
23
|
+
internal A/V skew correction (ffplay's is good for hours of playback).
|
|
24
|
+
|
|
25
|
+
Pause / seek invalidate the audio subprocess and we respawn at the new
|
|
26
|
+
position with `-ss <seconds>`. Spawn latency is ~250ms, accepted as the
|
|
27
|
+
cost of audio sync.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import signal
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _find_player() -> str | None:
|
|
39
|
+
"""Return the path to ffplay if installed, else None."""
|
|
40
|
+
return shutil.which("ffplay")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_available() -> bool:
|
|
44
|
+
return _find_player() is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_hint() -> str:
|
|
48
|
+
if sys.platform == "darwin":
|
|
49
|
+
return "Install with: brew install ffmpeg"
|
|
50
|
+
if sys.platform.startswith("linux"):
|
|
51
|
+
return "Install with: apt install ffmpeg (or your distro's equivalent)"
|
|
52
|
+
return "Install ffmpeg from https://ffmpeg.org/download.html"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AudioPlayer:
|
|
56
|
+
"""
|
|
57
|
+
Wraps an ffplay subprocess. Each method is best-effort — audio is a
|
|
58
|
+
nice-to-have; failures must never crash video playback.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, path: Path, volume: int = 100) -> None:
|
|
62
|
+
self.path = path
|
|
63
|
+
self.volume = max(0, min(100, volume))
|
|
64
|
+
self.muted = False
|
|
65
|
+
self._proc: subprocess.Popen | None = None
|
|
66
|
+
self._start_offset: float = 0.0 # seconds into the file
|
|
67
|
+
self._player = _find_player()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def available(self) -> bool:
|
|
71
|
+
return self._player is not None
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------ control
|
|
74
|
+
|
|
75
|
+
def start(self, position_sec: float = 0.0) -> None:
|
|
76
|
+
"""Start (or restart) audio at the given position."""
|
|
77
|
+
if not self.available:
|
|
78
|
+
return
|
|
79
|
+
self.stop()
|
|
80
|
+
self._start_offset = max(0.0, position_sec)
|
|
81
|
+
|
|
82
|
+
vol = 0 if self.muted else self.volume
|
|
83
|
+
cmd = [
|
|
84
|
+
self._player,
|
|
85
|
+
"-nodisp", # no video window
|
|
86
|
+
"-autoexit", # die when file ends
|
|
87
|
+
"-loglevel", "quiet",
|
|
88
|
+
"-volume", str(vol),
|
|
89
|
+
"-ss", f"{self._start_offset:.3f}",
|
|
90
|
+
str(self.path),
|
|
91
|
+
]
|
|
92
|
+
try:
|
|
93
|
+
self._proc = subprocess.Popen(
|
|
94
|
+
cmd,
|
|
95
|
+
stdin=subprocess.DEVNULL,
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
# New process group so SIGINT to our terminal doesn't reach it
|
|
99
|
+
# before we get a chance to cleanly SIGTERM.
|
|
100
|
+
start_new_session=True,
|
|
101
|
+
)
|
|
102
|
+
except (OSError, FileNotFoundError):
|
|
103
|
+
self._proc = None
|
|
104
|
+
|
|
105
|
+
def stop(self) -> None:
|
|
106
|
+
"""Terminate the audio subprocess if running. Idempotent."""
|
|
107
|
+
if self._proc is None:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
if self._proc.poll() is None:
|
|
111
|
+
# SIGTERM gives ffplay a chance to close its audio device cleanly,
|
|
112
|
+
# which avoids the "audio drop-out into next thing you play"
|
|
113
|
+
# CoreAudio bug on macOS.
|
|
114
|
+
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
|
115
|
+
try:
|
|
116
|
+
self._proc.wait(timeout=0.5)
|
|
117
|
+
except subprocess.TimeoutExpired:
|
|
118
|
+
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
|
119
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
120
|
+
pass
|
|
121
|
+
finally:
|
|
122
|
+
self._proc = None
|
|
123
|
+
|
|
124
|
+
def toggle_mute(self) -> bool:
|
|
125
|
+
"""Toggle mute. Returns new muted state. Requires respawn."""
|
|
126
|
+
self.muted = not self.muted
|
|
127
|
+
return self.muted
|
|
128
|
+
|
|
129
|
+
def set_volume(self, vol: int) -> None:
|
|
130
|
+
self.volume = max(0, min(100, vol))
|
|
131
|
+
|
|
132
|
+
def is_running(self) -> bool:
|
|
133
|
+
return self._proc is not None and self._proc.poll() is None
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ context
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> "AudioPlayer":
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *exc) -> None:
|
|
141
|
+
self.stop()
|
termview/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for `tv`.
|
|
3
|
+
|
|
4
|
+
Responsibilities:
|
|
5
|
+
- parse arguments
|
|
6
|
+
- dispatch to the appropriate playback path (image / animation / video)
|
|
7
|
+
- handle stdout-not-a-TTY and broken-pipe scenarios cleanly
|
|
8
|
+
- keep the actual rendering / playback logic out of this file
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import errno
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .detect import (
|
|
17
|
+
ColorDepth,
|
|
18
|
+
RendererType,
|
|
19
|
+
detect_color_depth,
|
|
20
|
+
detect_environment,
|
|
21
|
+
detect_renderer,
|
|
22
|
+
terminal_size,
|
|
23
|
+
)
|
|
24
|
+
from .loader import is_animated, is_image, is_video, load_image
|
|
25
|
+
from .renderers import get_renderer
|
|
26
|
+
from .resize import fit_image
|
|
27
|
+
from .stream import stream_animation, stream_video
|
|
28
|
+
|
|
29
|
+
_MIN_COLS = 20
|
|
30
|
+
_MIN_ROWS = 8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
try:
|
|
35
|
+
_main()
|
|
36
|
+
except BrokenPipeError:
|
|
37
|
+
# Piped output closed before we finished writing (e.g. `tv x.png | head`).
|
|
38
|
+
# Suppress and exit cleanly so we don't dump a traceback.
|
|
39
|
+
try:
|
|
40
|
+
sys.stdout.close()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
sys.exit(130)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _main() -> None:
|
|
49
|
+
parser = _build_parser()
|
|
50
|
+
args = parser.parse_args()
|
|
51
|
+
|
|
52
|
+
path: Path = args.file
|
|
53
|
+
if not path.exists():
|
|
54
|
+
parser.error(f"{path}: no such file or directory")
|
|
55
|
+
|
|
56
|
+
renderer_type = detect_renderer(args.renderer)
|
|
57
|
+
color_depth = detect_color_depth(args.depth)
|
|
58
|
+
cols, rows = terminal_size()
|
|
59
|
+
if args.width:
|
|
60
|
+
cols = args.width
|
|
61
|
+
|
|
62
|
+
env = detect_environment()
|
|
63
|
+
|
|
64
|
+
# Refuse to draw graphics into a non-TTY stdout — we'd corrupt whatever
|
|
65
|
+
# file or pipe the user redirected to with cursor/clear escapes. Allow
|
|
66
|
+
# `--renderer block` + image to still work if user is piping (it's just
|
|
67
|
+
# text), but disable cursor sequences in that case.
|
|
68
|
+
if not env["tty_stdout"] and is_video(path):
|
|
69
|
+
parser.error("video playback requires stdout to be a terminal")
|
|
70
|
+
|
|
71
|
+
if args.verbose:
|
|
72
|
+
_print_diagnostics(renderer_type, color_depth, cols, rows, env)
|
|
73
|
+
|
|
74
|
+
# Tiny-terminal guard.
|
|
75
|
+
if cols < _MIN_COLS or rows < _MIN_ROWS:
|
|
76
|
+
sys.stderr.write(
|
|
77
|
+
f"tv: terminal too small ({cols}x{rows}); "
|
|
78
|
+
f"need at least {_MIN_COLS}x{_MIN_ROWS}.\n"
|
|
79
|
+
)
|
|
80
|
+
sys.exit(2)
|
|
81
|
+
|
|
82
|
+
if is_image(path):
|
|
83
|
+
img = load_image(path)
|
|
84
|
+
if is_animated(img):
|
|
85
|
+
stream_animation(
|
|
86
|
+
img,
|
|
87
|
+
renderer_type,
|
|
88
|
+
color_depth=color_depth,
|
|
89
|
+
enable_controls=not args.no_controls,
|
|
90
|
+
loop=args.loop,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
_display_image(img, renderer_type, color_depth, cols, rows, args)
|
|
94
|
+
elif is_video(path):
|
|
95
|
+
stream_video(
|
|
96
|
+
path,
|
|
97
|
+
renderer_type,
|
|
98
|
+
color_depth=color_depth,
|
|
99
|
+
fps_limit=args.fps,
|
|
100
|
+
enable_audio=not args.no_audio,
|
|
101
|
+
enable_controls=not args.no_controls,
|
|
102
|
+
verbose=args.verbose,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
parser.error(f"{path}: unsupported file type")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _display_image(img, renderer_type, color_depth, cols, rows, args) -> None:
|
|
109
|
+
original_size = img.size
|
|
110
|
+
fitted = fit_image(img, cols, rows, renderer_type, crop=not args.no_crop)
|
|
111
|
+
if args.verbose:
|
|
112
|
+
print(
|
|
113
|
+
f"[tv] image {original_size[0]}x{original_size[1]} "
|
|
114
|
+
f"-> render {fitted.size[0]}x{fitted.size[1]}",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
renderer = get_renderer(renderer_type, color_depth=color_depth)
|
|
118
|
+
renderer.display(fitted)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_diagnostics(renderer_type, color_depth, cols, rows, env) -> None:
|
|
122
|
+
parts = [
|
|
123
|
+
f"renderer={renderer_type.value}",
|
|
124
|
+
f"color={color_depth.value}",
|
|
125
|
+
f"terminal={cols}x{rows}",
|
|
126
|
+
]
|
|
127
|
+
if env["tmux"]:
|
|
128
|
+
parts.append("tmux=yes")
|
|
129
|
+
if env["ssh"]:
|
|
130
|
+
parts.append("ssh=yes")
|
|
131
|
+
print(f"[tv] {' '.join(parts)}", file=sys.stderr)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------- argparse
|
|
135
|
+
|
|
136
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
137
|
+
p = argparse.ArgumentParser(
|
|
138
|
+
prog="tv",
|
|
139
|
+
description="View images, animations, and videos in the terminal.",
|
|
140
|
+
epilog=_CONTROLS_HELP,
|
|
141
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
142
|
+
)
|
|
143
|
+
p.add_argument("file", type=Path, help="Image, animated GIF, or video file")
|
|
144
|
+
|
|
145
|
+
g_render = p.add_argument_group("rendering")
|
|
146
|
+
g_render.add_argument(
|
|
147
|
+
"--renderer",
|
|
148
|
+
choices=[r.value for r in RendererType],
|
|
149
|
+
metavar="NAME",
|
|
150
|
+
help="Force renderer: kitty | iterm2 | sixel | block (default: auto)",
|
|
151
|
+
)
|
|
152
|
+
g_render.add_argument(
|
|
153
|
+
"--depth",
|
|
154
|
+
choices=["truecolor", "256"],
|
|
155
|
+
metavar="DEPTH",
|
|
156
|
+
help="Force color depth (default: auto)",
|
|
157
|
+
)
|
|
158
|
+
g_render.add_argument(
|
|
159
|
+
"--width",
|
|
160
|
+
type=int,
|
|
161
|
+
metavar="COLS",
|
|
162
|
+
help="Override terminal width in columns",
|
|
163
|
+
)
|
|
164
|
+
g_render.add_argument(
|
|
165
|
+
"--no-crop",
|
|
166
|
+
action="store_true",
|
|
167
|
+
help="Disable automatic border cropping (still images only)",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
g_video = p.add_argument_group("video / animation")
|
|
171
|
+
g_video.add_argument(
|
|
172
|
+
"--fps",
|
|
173
|
+
type=float,
|
|
174
|
+
metavar="N",
|
|
175
|
+
help="Limit playback frame rate (default: auto; 12 on 256-color)",
|
|
176
|
+
)
|
|
177
|
+
g_video.add_argument(
|
|
178
|
+
"--no-audio",
|
|
179
|
+
action="store_true",
|
|
180
|
+
help="Disable audio playback (video only)",
|
|
181
|
+
)
|
|
182
|
+
g_video.add_argument(
|
|
183
|
+
"--no-controls",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help="Disable keyboard controls (no pause/seek; for scripts/recording)",
|
|
186
|
+
)
|
|
187
|
+
g_video.add_argument(
|
|
188
|
+
"--loop",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="Loop animated images (GIF/WebP/APNG). Default: loop forever.",
|
|
191
|
+
default=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
p.add_argument(
|
|
195
|
+
"-v",
|
|
196
|
+
"--verbose",
|
|
197
|
+
action="store_true",
|
|
198
|
+
help="Print detection results and per-frame info to stderr",
|
|
199
|
+
)
|
|
200
|
+
return p
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_CONTROLS_HELP = """\
|
|
204
|
+
Playback controls (video):
|
|
205
|
+
space play / pause
|
|
206
|
+
← / → seek -5s / +5s
|
|
207
|
+
↓ / ↑ seek -30s / +30s
|
|
208
|
+
, / . previous / next frame (while paused)
|
|
209
|
+
m mute / unmute
|
|
210
|
+
+ / - volume up / down
|
|
211
|
+
0 restart from beginning
|
|
212
|
+
q / esc quit
|
|
213
|
+
|
|
214
|
+
Requires ffmpeg for audio. Without it, video plays silently.
|
|
215
|
+
"""
|
termview/controls.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Non-blocking keystroke reading for interactive video playback.
|
|
3
|
+
|
|
4
|
+
Reads stdin one byte at a time and decodes:
|
|
5
|
+
- printable ASCII (space, q, m, f, comma, period, plus, minus, etc.)
|
|
6
|
+
- escape sequences for arrow keys + shift-arrows
|
|
7
|
+
- bare ESC (also returned for "quit")
|
|
8
|
+
|
|
9
|
+
Designed to be called once per frame with the frame-budget as timeout, so it
|
|
10
|
+
gives keystrokes near-immediate response while never blocking the render loop.
|
|
11
|
+
Assumes stdin is already in cbreak mode (see TerminalState).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import select
|
|
15
|
+
import sys
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Key(Enum):
|
|
20
|
+
NONE = "none"
|
|
21
|
+
QUIT = "quit"
|
|
22
|
+
PAUSE = "pause" # space
|
|
23
|
+
SEEK_BACK = "seek_back" # left arrow
|
|
24
|
+
SEEK_FWD = "seek_fwd" # right arrow
|
|
25
|
+
SEEK_BACK_BIG = "seek_back_big" # shift+left or down
|
|
26
|
+
SEEK_FWD_BIG = "seek_fwd_big" # shift+right or up
|
|
27
|
+
FRAME_PREV = "frame_prev" # ,
|
|
28
|
+
FRAME_NEXT = "frame_next" # .
|
|
29
|
+
MUTE = "mute" # m
|
|
30
|
+
VOL_UP = "vol_up" # + or =
|
|
31
|
+
VOL_DOWN = "vol_down" # -
|
|
32
|
+
RESTART = "restart" # 0 or home
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_key(timeout: float) -> Key:
|
|
36
|
+
"""
|
|
37
|
+
Read at most one key event from stdin, waiting up to *timeout* seconds.
|
|
38
|
+
Returns Key.NONE if nothing arrived in that window.
|
|
39
|
+
|
|
40
|
+
Caller must ensure stdin is in cbreak (or raw) mode and is a TTY.
|
|
41
|
+
"""
|
|
42
|
+
if not sys.stdin.isatty():
|
|
43
|
+
return Key.NONE
|
|
44
|
+
|
|
45
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
46
|
+
if not ready:
|
|
47
|
+
return Key.NONE
|
|
48
|
+
|
|
49
|
+
ch = sys.stdin.read(1)
|
|
50
|
+
if not ch:
|
|
51
|
+
return Key.NONE
|
|
52
|
+
|
|
53
|
+
# Single-character bindings.
|
|
54
|
+
simple = {
|
|
55
|
+
" ": Key.PAUSE,
|
|
56
|
+
"q": Key.QUIT,
|
|
57
|
+
"Q": Key.QUIT,
|
|
58
|
+
",": Key.FRAME_PREV,
|
|
59
|
+
"<": Key.FRAME_PREV,
|
|
60
|
+
".": Key.FRAME_NEXT,
|
|
61
|
+
">": Key.FRAME_NEXT,
|
|
62
|
+
"m": Key.MUTE,
|
|
63
|
+
"M": Key.MUTE,
|
|
64
|
+
"+": Key.VOL_UP,
|
|
65
|
+
"=": Key.VOL_UP,
|
|
66
|
+
"-": Key.VOL_DOWN,
|
|
67
|
+
"_": Key.VOL_DOWN,
|
|
68
|
+
"0": Key.RESTART,
|
|
69
|
+
}
|
|
70
|
+
if ch in simple:
|
|
71
|
+
return simple[ch]
|
|
72
|
+
|
|
73
|
+
# ESC starts either a bare escape (= quit) or a CSI sequence.
|
|
74
|
+
if ch != "\033":
|
|
75
|
+
return Key.NONE
|
|
76
|
+
|
|
77
|
+
# Peek for a follow-up byte; if none arrives in 50ms it was a bare ESC.
|
|
78
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.05)
|
|
79
|
+
if not ready:
|
|
80
|
+
return Key.QUIT
|
|
81
|
+
|
|
82
|
+
if sys.stdin.read(1) != "[":
|
|
83
|
+
return Key.NONE # unrecognized ESC-anything
|
|
84
|
+
|
|
85
|
+
# CSI sequence. Read the final byte (and the optional modifier digits).
|
|
86
|
+
seq = ""
|
|
87
|
+
while True:
|
|
88
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.05)
|
|
89
|
+
if not ready:
|
|
90
|
+
break
|
|
91
|
+
b = sys.stdin.read(1)
|
|
92
|
+
seq += b
|
|
93
|
+
# Final bytes of a CSI sequence are in the 0x40-0x7E range.
|
|
94
|
+
if "@" <= b <= "~":
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
# Common arrow keys
|
|
98
|
+
if seq == "A":
|
|
99
|
+
return Key.SEEK_FWD_BIG # up: +30s
|
|
100
|
+
if seq == "B":
|
|
101
|
+
return Key.SEEK_BACK_BIG # down: -30s
|
|
102
|
+
if seq == "C":
|
|
103
|
+
return Key.SEEK_FWD # right: +5s
|
|
104
|
+
if seq == "D":
|
|
105
|
+
return Key.SEEK_BACK # left: -5s
|
|
106
|
+
# Shift-arrows arrive as CSI 1;2A / 1;2B / 1;2C / 1;2D in xterm-style.
|
|
107
|
+
if seq.endswith("C") and "2" in seq:
|
|
108
|
+
return Key.SEEK_FWD_BIG
|
|
109
|
+
if seq.endswith("D") and "2" in seq:
|
|
110
|
+
return Key.SEEK_BACK_BIG
|
|
111
|
+
if seq == "H":
|
|
112
|
+
return Key.RESTART # Home
|
|
113
|
+
|
|
114
|
+
return Key.NONE
|