use crate::{
common::{errors::*, sync::PlaybackClock},
msg::broker::Control as MediaControl,
subtitle::SubtitleManager,
StringInfo,
};
use crossbeam_channel::{Receiver, Sender};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor, Stylize},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
};
use std::{
io::{stdout, Result as IOResult, Write},
sync::{Arc, RwLock},
time::Duration,
};
#[derive(PartialEq)]
enum State {
Running,
Paused,
Stopped,
}
const SEEK_STEP_SECONDS: f64 = 5.0;
const SUBTITLE_LINES: u16 = 1;
const SUBTITLE_SIZE: f64 = 1.0;
const SUBTITLE_BASE_WIDTH_PERCENT: f64 = 0.6;
pub struct Terminal {
fg_color: Color,
bg_color: Color,
title: String,
state: State,
rx_buffer: Receiver<Option<StringInfo>>,
tx_control: Sender<MediaControl>,
use_grayscale: bool,
subtitle_text: Option<Arc<RwLock<String>>>,
subtitles_enabled: bool,
terminal_width: u16,
terminal_height: u16,
local_subtitles: Option<SubtitleManager>,
playback_clock: Option<Arc<PlaybackClock>>,
}
impl Terminal {
pub fn new(
title: String,
use_grayscale: bool,
rx_buffer: Receiver<Option<StringInfo>>,
tx_control: Sender<MediaControl>,
subtitle_text: Option<Arc<RwLock<String>>>,
local_subtitles: Option<SubtitleManager>,
playback_clock: Option<Arc<PlaybackClock>>,
) -> Self {
Self {
fg_color: Color::White,
bg_color: Color::Black,
title,
state: State::Running,
rx_buffer,
tx_control,
use_grayscale,
subtitle_text,
subtitles_enabled: false,
terminal_width: 80,
terminal_height: 24,
local_subtitles,
playback_clock,
}
}
pub fn run(&mut self, barrier: std::sync::Arc<std::sync::Barrier>) -> Result<(), MyError> {
execute!(stdout(), EnterAlternateScreen, SetTitle(&self.title))?;
terminal::enable_raw_mode()?;
self.clear()?;
let (width, height) = terminal::size()?;
self.terminal_width = width;
self.terminal_height = height;
let video_height = if self.subtitles_enabled {
height.saturating_sub(SUBTITLE_LINES)
} else {
height
};
self.send_control(MediaControl::Resize(width, video_height))?;
barrier.wait();
while self.state != State::Stopped {
if event::poll(Duration::from_millis(0))? {
let ev = event::read()?;
self.handle_event(ev)?;
}
match self.rx_buffer.try_recv() {
Ok(Some(s)) => {
self.draw(&s)?;
self.draw_subtitle()?;
}
Ok(None) => {
}
Err(crossbeam_channel::TryRecvError::Empty) => {
}
Err(crossbeam_channel::TryRecvError::Disconnected) => {
self.state = State::Stopped;
}
}
}
self.cleanup()?;
Ok(())
}
fn clear(&self) -> IOResult<()> {
execute!(
stdout(),
Clear(ClearType::All),
Hide,
SetForegroundColor(self.fg_color),
SetBackgroundColor(self.bg_color),
MoveTo(0, 0),
ResetColor,
)?;
stdout().flush()?;
Ok(())
}
fn cleanup(&self) -> IOResult<()> {
execute!(
stdout(),
ResetColor,
Clear(ClearType::All),
Show,
LeaveAlternateScreen
)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(&self, (string, rgb_data): &StringInfo) -> IOResult<()> {
let print_string = |string: &str| {
let mut out = stdout();
execute!(out, MoveTo(0, 0), Print(string), MoveTo(0, 0))?;
out.flush()?;
Ok(())
};
if self.use_grayscale {
print_string(string)
} else {
let mut colored_string = String::with_capacity(string.len() * 10);
for (c, rgb) in string.chars().zip(rgb_data.chunks(3)) {
let color = Color::Rgb {
r: rgb[0],
g: rgb[1],
b: rgb[2],
};
colored_string.push_str(&format!("{}", c.stylize().with(color)));
}
print_string(&colored_string)
}
}
fn draw_subtitle(&mut self) -> IOResult<()> {
if !self.subtitles_enabled {
return Ok(());
}
let subtitle = self.get_current_subtitle();
if subtitle.is_empty() {
let subtitle_y = self.terminal_height.saturating_sub(SUBTITLE_LINES);
let full_width = self.terminal_width as usize;
let mut out = stdout();
for y in subtitle_y..self.terminal_height {
execute!(
out,
MoveTo(0, y),
SetBackgroundColor(Color::Rgb { r: 40, g: 40, b: 40 }),
Print(" ".repeat(full_width)),
ResetColor
)?;
}
out.flush()?;
return Ok(());
}
let full_width = self.terminal_width as usize;
let target_width = (full_width as f64 * SUBTITLE_BASE_WIDTH_PERCENT * SUBTITLE_SIZE) as usize;
let max_width = target_width.max(40).min(full_width.saturating_sub(4));
let wrapped_lines = self.wrap_text(&subtitle, max_width);
let lines_count = wrapped_lines.len();
let max_visual_lines = (self.terminal_height as usize / 4).max(3);
let lines_to_draw = lines_count.min(max_visual_lines);
let subtitle_y = self.terminal_height.saturating_sub(lines_to_draw as u16);
let mut out = stdout();
for i in 0..lines_to_draw {
let y = subtitle_y + i as u16;
execute!(
out,
MoveTo(0, y),
SetBackgroundColor(Color::Rgb { r: 40, g: 40, b: 40 }),
Print(" ".repeat(full_width)),
ResetColor
)?;
}
for (i, line) in wrapped_lines.iter().take(lines_to_draw).enumerate() {
let y = subtitle_y + i as u16;
let padding = full_width.saturating_sub(line.len()) / 2;
let padded_line = format!("{:>width$}", line, width = padding + line.len());
let fill_spaces = full_width.saturating_sub(padded_line.len());
execute!(
out,
MoveTo(0, y),
SetBackgroundColor(Color::Rgb { r: 40, g: 40, b: 40 }),
SetForegroundColor(Color::Rgb { r: 255, g: 255, b: 255 }),
SetAttribute(Attribute::Bold),
Print(&padded_line),
Print(" ".repeat(fill_spaces)),
SetAttribute(Attribute::Reset),
ResetColor
)?;
}
out.flush()?;
Ok(())
}
fn get_current_subtitle(&self) -> String {
if let Some(ref manager) = self.local_subtitles {
if let Some(ref clock) = self.playback_clock {
let pos = clock.get_position();
if let Some(text) = manager.get_subtitle_at(pos) {
return text.to_string();
}
}
}
if let Some(ref lock) = self.subtitle_text {
if let Ok(text) = lock.read() {
return text.clone();
}
}
String::new()
}
fn wrap_text(&self, text: &str, max_width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
lines
}
fn handle_event(&mut self, event: Event) -> IOResult<()> {
match event {
Event::Key(KeyEvent {
code: KeyCode::Char('q') | KeyCode::Char('Q'),
..
})
| Event::Key(KeyEvent {
code: KeyCode::Char('c') | KeyCode::Char('C'),
modifiers: event::KeyModifiers::CONTROL,
..
})
| Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => {
self.state = State::Stopped;
self.send_control(MediaControl::Exit)?;
}
Event::Key(KeyEvent {
code: KeyCode::Char(' '),
..
}) => {
self.send_control(MediaControl::PauseContinue)?;
self.state = match self.state {
State::Running => State::Paused,
State::Paused => State::Running,
State::Stopped => State::Stopped,
};
}
Event::Resize(width, height) => {
self.terminal_width = width;
self.terminal_height = height;
let video_height = if self.subtitles_enabled {
height.saturating_sub(SUBTITLE_LINES)
} else {
height
};
self.send_control(MediaControl::Resize(width, video_height))?;
while self
.rx_buffer
.recv_timeout(Duration::from_millis(1))
.is_ok()
{ }
}
Event::Key(KeyEvent {
code: KeyCode::Char(digit),
..
}) if digit.is_ascii_digit() => {
self.send_control(MediaControl::SetCharMap(digit.to_digit(10).unwrap_or_else(
|| panic!("{error}: {digit:?}", error = ERROR_PARSE_DIGIT_FAILED),
)))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('g') | KeyCode::Char('G'),
..
}) => {
self.use_grayscale = !self.use_grayscale;
self.send_control(MediaControl::SetGrayscale(self.use_grayscale))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('m') | KeyCode::Char('M'),
..
}) => {
self.send_control(MediaControl::MuteUnmute)?;
}
Event::Key(KeyEvent {
code: KeyCode::Right,
..
}) => {
self.send_control(MediaControl::Seek(SEEK_STEP_SECONDS))?;
}
Event::Key(KeyEvent {
code: KeyCode::Left,
..
}) => {
self.send_control(MediaControl::Seek(-SEEK_STEP_SECONDS))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('C'),
modifiers: KeyModifiers::SHIFT,
..
}) => {
self.subtitles_enabled = !self.subtitles_enabled;
if let Some(ref mut manager) = self.local_subtitles {
manager.set_enabled(self.subtitles_enabled);
}
self.send_control(MediaControl::ToggleSubtitle)?;
let video_height = if self.subtitles_enabled {
self.terminal_height.saturating_sub(SUBTITLE_LINES)
} else {
self.terminal_height
};
self.send_control(MediaControl::Resize(self.terminal_width, video_height))?;
if !self.subtitles_enabled {
let subtitle_y = self.terminal_height.saturating_sub(SUBTITLE_LINES);
let clear_line = " ".repeat(self.terminal_width as usize);
let mut out = stdout();
for y in subtitle_y..self.terminal_height {
let _ = execute!(out, MoveTo(0, y), Print(&clear_line));
}
let _ = out.flush();
}
while self
.rx_buffer
.recv_timeout(Duration::from_millis(1))
.is_ok()
{ }
}
Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::NONE,
..
}) => {
if let Some(ref mut manager) = self.local_subtitles {
manager.cycle_track();
}
self.send_control(MediaControl::CycleSubtitle)?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('['),
..
}) => {
self.send_control(MediaControl::AdjustSpeed(-0.25))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char(']'),
..
}) => {
self.send_control(MediaControl::AdjustSpeed(0.25))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char(','),
..
}) => {
self.send_control(MediaControl::AdjustSpeed(-0.1))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('.'),
..
}) => {
self.send_control(MediaControl::AdjustSpeed(0.1))?;
}
Event::Key(KeyEvent {
code: KeyCode::Char('\\'),
..
}) => {
self.send_control(MediaControl::ResetSpeed)?;
}
_ => {}
}
Ok(())
}
fn send_control(&self, control: MediaControl) -> Result<(), MyError> {
self.tx_control
.send(control)
.map_err(|e| MyError::Terminal(format!("{error}: {e:?}", error = ERROR_CHANNEL, e = e)))
}
}