├── .gitignore ├── .travis.yml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── appveyor.yml ├── assets └── screenshots │ └── media-toc-player_video.png ├── build.rs ├── ci └── before_install.sh ├── flatpak └── org.fengalin.media-toc-player.json ├── po ├── .gitignore ├── LINGUAS ├── POTFILES.in ├── es.po ├── fr.po └── update ├── res ├── org.fengalin.media-toc-player.desktop └── ui │ ├── media-toc-player.ui │ └── ui.gresource.xml └── src ├── application ├── command_line.rs ├── configuration.rs ├── locale.rs └── mod.rs ├── main.rs ├── media ├── mod.rs ├── playback_pipeline.rs └── timestamp.rs ├── metadata ├── duration.rs ├── factory.rs ├── format.rs ├── media_info.rs ├── mkvmerge_text_format.rs ├── mod.rs ├── timestamp_4_humans.rs ├── toc_visitor.rs └── ui_event.rs └── ui ├── chapter_tree_manager.rs ├── image.rs ├── info_bar_controller.rs ├── info_controller.rs ├── info_dispatcher.rs ├── main_controller.rs ├── main_dispatcher.rs ├── mod.rs ├── perspective_controller.rs ├── perspective_dispatcher.rs ├── streams_controller.rs ├── streams_dispatcher.rs ├── ui_event.rs ├── video_controller.rs └── video_dispatcher.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock 7 | # Note: keep it for the moment in order to stick to the actual git versions 8 | # used during the development 9 | #Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # Editors' backup 15 | *~ 16 | 17 | # Flatpak build tree 18 | .flatpak-builder 19 | 20 | # gnome-builder build config file 21 | .buildconfig -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | 3 | matrix: 4 | include: 5 | - os: linux 6 | rust: stable 7 | dist: bionic 8 | - os: osx 9 | rust: stable 10 | 11 | addons: 12 | apt: 13 | packages: 14 | - libgtk-3-dev 15 | 16 | cache: 17 | cargo: true 18 | 19 | before_install: 20 | - . ci/before_install.sh 21 | 22 | script: 23 | - rustc --version 24 | - cargo build 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["François Laignel "] 3 | build = "build.rs" 4 | description = "A media player with a table of contents" 5 | edition = '2018' 6 | homepage = "https://https://github.com/fengalin/media-toc" 7 | keywords = ["multimedia", "chapter", "table-of-contents", "gtk3", "gstreamer"] 8 | license = "MIT" 9 | name = "media-toc-player" 10 | readme = "README.md" 11 | repository = "https://https://github.com/fengalin/media-toc-player" 12 | version = "0.1.99" 13 | 14 | [dependencies] 15 | bitflags = "1" 16 | cairo-rs = { git = "https://github.com/gtk-rs/gtk-rs" } 17 | clap = "2" 18 | directories = "3" 19 | env_logger = "0.7" 20 | futures = "0.3" 21 | gdk = { git = "https://github.com/gtk-rs/gtk-rs" } 22 | gettext-rs = { version = "0.4", features = ["gettext-system"] } 23 | gio = { git = "https://github.com/gtk-rs/gtk-rs" } 24 | glib = { git = "https://github.com/gtk-rs/gtk-rs" } 25 | gst = { package = "gstreamer", git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs", features = ["v1_10"] } 26 | gtk = { git = "https://github.com/gtk-rs/gtk-rs", features = ["v3_20"] } 27 | image = "0.23" 28 | lazy_static = "1" 29 | log = { version = "0.4", features = ["max_level_debug", "release_max_level_warn"] } 30 | nom = "5" 31 | ron = "0.6" 32 | serde = "1" 33 | serde_derive = "1" 34 | 35 | [build-dependencies] 36 | directories = "3" 37 | lazy_static = "1" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # media-toc-player [![Build Status](https://travis-ci.org/fengalin/media-toc-player.svg?branch=master)](https://travis-ci.org/fengalin/media-toc-player) [![Build status](https://ci.appveyor.com/api/projects/status/yc1gba3o1h69t3g3?svg=true)](https://ci.appveyor.com/project/fengalin/media-toc-player) 2 | **media-toc-player** is a media player with a table of contents which allows seeking 3 | to a given chapter and optionally looping on current chapter. 4 | 5 | **media-toc-player** is a simplication of [media-toc](https://github.com/fengalin/media-toc), 6 | an application to create and edit a table of contents from a media file. It is 7 | primarily developed in Rust on Linux, it runs on Windows and should also work on macOS. 8 | 9 | ## Table of contents 10 | - [Screenshots](#ui) 11 | - [Features](#features) 12 | - [TODO](#todo) 13 | - [Accelerators](#accelerators) 14 | - [Technologies](#technologies) 15 | - [Build environment](#build-env) 16 | - [Build and run](#build-run) 17 | - [Troubleshooting](#troubleshooting) 18 | 19 | ## Screenshot 20 | ![media-toc-player UI Video](assets/screenshots/media-toc-player_video.png) 21 | 22 | # Features 23 | - Play any media supported by the installed GStreamer plugins. 24 | - Select the video / audio stream to play. 25 | - Show the chapters list for the media. 26 | - Move to a chapter by clicking on its entry the list. 27 | - Loop on current chapter. 28 | 29 | # TODO 30 | - Switch to full screen mode. 31 | - Display subtitles. 32 | - Make timeline foldable. 33 | - Finalize flatpak and deal with potential license issues with plugins. 34 | 35 | ## Accelerators 36 | 37 | The following functions are bound to one or multiple key accelerators: 38 | 39 | | Function | keys | 40 | | ---------------------------------------------------------- | :---------------: | 41 | | Open media dialog | + O | 42 | | Quit the application | + Q | 43 | | Play/Pause (and open media dialog when no media is loaded) | Space or Play key | 44 | | Step forward | Right | 45 | | Step back | Left | 46 | | Go to next chapter | Down or Next key | 47 | | Go to the beginning of current chapter or previous chapter | Up or Prev key | 48 | | Close the info bar | Escape | 49 | | Toggle show/hide chapters list | L | 50 | | Toggle repeat current chapter | R | 51 | | Show the Display perspective | F5 | 52 | | Show the Streams perspective | F6 | 53 | | Open the about dialog | + A | 54 | 55 | # Technologies 56 | **media-toc-player** is developed in Rust and uses the following technologies: 57 | - **GTK-3** ([official documentation](https://developer.gnome.org/gtk3/stable/), 58 | [Rust binding](http://gtk-rs.org/docs/gtk/)) and [Glade](https://glade.gnome.org/). 59 | - **GStreamer** ([official documentation](https://gstreamer.freedesktop.org/documentation/), 60 | [Rust binding](https://sdroege.github.io/rustdoc/gstreamer/gstreamer/)). 61 | 62 | # Environment preparation 63 | ## Toolchain 64 | ``` 65 | $ curl https://sh.rustup.rs -sSf | sh 66 | ``` 67 | Select the stable toolchain. See the full documentation 68 | [here](https://github.com/rust-lang-nursery/rustup.rs#installation). 69 | 70 | ## Dependencies 71 | Rust dependencies are handled by [Cargo](http://doc.crates.io/). You will also 72 | need the following packages installed on your OS: 73 | 74 | ### Fedora 75 | ``` 76 | sudo dnf install gtk3-devel glib2-devel gstreamer1-devel \ 77 | gstreamer1-plugins-base-devel gstreamer1-plugins-{good,bad-free,ugly-free} \ 78 | gstreamer1-libav 79 | ``` 80 | 81 | ### Debian & Ubuntu 82 | ``` 83 | sudo apt-get install libgtk-3-dev libgstreamer1.0-dev \ 84 | libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-{good,bad,ugly} \ 85 | gstreamer1.0-libav 86 | ``` 87 | 88 | ### macOS 89 | ``` 90 | brew install gtk+3 gstreamer 91 | brew install --with-libvorbis --with-opus --with-theora gst-plugins-base 92 | brew install --with-flac --with-gtk+3 --with-libpng --with-taglib gst-plugins-good 93 | brew install --with-srt gst-plugins-bad 94 | brew install --with-libmpeg2 --with-x264 gst-plugins-ugly 95 | ``` 96 | 97 | The package `adwaita-icon-theme` might allow installing the missing icons, but 98 | it fails while compiling the Rust compiler (which is used to compile `librsvg`). 99 | I'll try to configure the formula so that it uses the installed compiler when I 100 | get time. 101 | 102 | Use the following command to build and generate locales: 103 | ``` 104 | PATH="/usr/local/opt/gettext/bin:$PATH" cargo build --release 105 | ``` 106 | 107 | ### Windows 108 | - MSYS2: follow [this guide](http://www.msys2.org/). 109 | - Install the development toolchain, GTK and GStreamer
110 | Note: for a 32bits system, use `mingw-w64-i686-...` 111 | ``` 112 | pacman --noconfirm -S gettext-devel mingw-w64-x86_64-gtk3 mingw-w64-x86_64-gstreamer 113 | pacman --noconfirm -S mingw-w64-x86_64-gst-plugins-{base,good,bad,ugly} mingw-w64-x86_64-gst-libav 114 | ``` 115 | 116 | - Launch the [rustup installer](https://www.rustup.rs/). 117 | When asked for the default host triple, select `x86_64-pc-windows-gnu` (or 118 | `i686-pc-windows-gnu` for a 32bits system), then select `stable`. 119 | - From a MSYS2 mingw shell 120 | - add cargo to the `PATH`: 121 | ``` 122 | echo 'PATH=$PATH:/c/Users/'$USER'/.cargo/bin' >> /home/$USER/.bashrc 123 | ``` 124 | - Restart the MSYS2 shell before using `cargo`. 125 | 126 | # Build and run 127 | Use Cargo (from the root of the project directory): 128 | ``` 129 | $ cargo run --release 130 | ``` 131 | 132 | # Troubleshooting 133 | 134 | ## Discarding the translations 135 | 136 | *media-toc-player* is currently available in English and French. The user's 137 | locale should be automatically detected. If you want to use the English version 138 | or if you want to submit logs, you can discard the translations using the 139 | following command: 140 | 141 | ``` 142 | LC_MESSAGES=C cargo run --release 143 | ``` 144 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - RUST: stable 4 | BITS: 32 5 | - RUST: stable 6 | BITS: 64 7 | 8 | install: 9 | - IF "%BITS%" == "32" SET ARCH=i686 10 | - IF "%BITS%" == "64" SET ARCH=x86_64 11 | - curl -sSf -o rustup-init.exe https://win.rustup.rs 12 | - rustup-init.exe --default-host "%ARCH%-pc-windows-gnu" --default-toolchain %RUST% -y 13 | - SET PATH=C:\Users\appveyor\.cargo\bin;C:\msys64\mingw%BITS%\bin;%PATH%;C:\msys64\usr\bin 14 | - rustc -Vv 15 | - cargo -Vv 16 | - pacman --noconfirm -S mingw-w64-%ARCH%-gtk3 mingw-w64-%ARCH%-gstreamer mingw-w64-%ARCH%-gst-plugins-base 17 | 18 | build_script: 19 | - cargo build 20 | 21 | test: false 22 | -------------------------------------------------------------------------------- /assets/screenshots/media-toc-player_video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fengalin/media-toc-player/8fb6580419ec530329c131116c726bb176348956/assets/screenshots/media-toc-player_video.png -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_family = "unix")] 2 | use directories::{BaseDirs, ProjectDirs}; 3 | 4 | use lazy_static::lazy_static; 5 | 6 | use std::fs::{create_dir_all, File}; 7 | use std::io::{ErrorKind, Read}; 8 | use std::path::PathBuf; 9 | use std::process::Command; 10 | 11 | #[cfg(target_family = "unix")] 12 | use std::io::Write; 13 | 14 | lazy_static! { 15 | // Remove "-application" from `CARGO_PKG_NAME` 16 | pub static ref APP_NAME: String = env!("CARGO_PKG_NAME").to_string(); 17 | } 18 | 19 | fn po_path() -> PathBuf { 20 | PathBuf::from("po") 21 | } 22 | 23 | fn res_path() -> PathBuf { 24 | PathBuf::from("res") 25 | } 26 | 27 | fn target_path() -> PathBuf { 28 | PathBuf::from("target") 29 | } 30 | 31 | fn generate_resources() { 32 | let output_path = target_path().join("resources"); 33 | create_dir_all(&output_path).unwrap(); 34 | 35 | // UI 36 | let input_path = res_path().join("ui"); 37 | 38 | let mut compile_res = Command::new("glib-compile-resources"); 39 | compile_res 40 | .arg("--generate") 41 | .arg(format!("--sourcedir={}", input_path.to_str().unwrap())) 42 | .arg(format!( 43 | "--target={}", 44 | output_path.join("ui.gresource").to_str().unwrap(), 45 | )) 46 | .arg(input_path.join("ui.gresource.xml").to_str().unwrap()); 47 | 48 | match compile_res.status() { 49 | Ok(status) => { 50 | if !status.success() { 51 | panic!(format!( 52 | "Failed to generate resources file for the UI\n{:?}", 53 | compile_res, 54 | )); 55 | } 56 | } 57 | Err(ref error) => match error.kind() { 58 | ErrorKind::NotFound => panic!( 59 | "Can't generate UI resources: command `glib-compile-resources` not available" 60 | ), 61 | _ => panic!("Error invoking `compile_res`: {}", error), 62 | }, 63 | } 64 | } 65 | 66 | fn generate_translations() { 67 | if let Ok(mut linguas_file) = File::open(&po_path().join("LINGUAS")) { 68 | let mut linguas = String::new(); 69 | linguas_file 70 | .read_to_string(&mut linguas) 71 | .expect("Couldn't read po/LINGUAS as string"); 72 | 73 | for lingua in linguas.lines() { 74 | let mo_path = target_path() 75 | .join("locale") 76 | .join(lingua) 77 | .join("LC_MESSAGES"); 78 | create_dir_all(&mo_path).unwrap(); 79 | 80 | let mut msgfmt = Command::new("msgfmt"); 81 | msgfmt 82 | .arg(format!( 83 | "--output-file={}", 84 | mo_path.join("media-toc-player.mo").to_str().unwrap() 85 | )) 86 | .arg(format!("--directory={}", po_path().to_str().unwrap())) 87 | .arg(format!("{}.po", lingua)); 88 | 89 | match msgfmt.status() { 90 | Ok(status) => { 91 | if !status.success() { 92 | panic!(format!( 93 | "Failed to generate mo file for lingua {}\n{:?}", 94 | lingua, msgfmt, 95 | )); 96 | } 97 | } 98 | Err(ref error) => match error.kind() { 99 | ErrorKind::NotFound => { 100 | eprintln!("Can't generate translations: command `msgfmt` not available"); 101 | return; 102 | } 103 | _ => panic!("Error invoking `msgfmt`: {}", error), 104 | }, 105 | } 106 | } 107 | } 108 | } 109 | 110 | // FIXME: figure out macOS conventions for icons & translations 111 | #[cfg(target_family = "unix")] 112 | fn generate_install_script() { 113 | let base_dirs = BaseDirs::new().unwrap(); 114 | // Note: `base_dirs.executable_dir()` is `None` on macOS 115 | if let Some(exe_dir) = base_dirs.executable_dir() { 116 | let project_dirs = ProjectDirs::from("org", "fengalin", &APP_NAME).unwrap(); 117 | let app_data_dir = project_dirs.data_dir(); 118 | let data_dir = app_data_dir.parent().unwrap(); 119 | 120 | match File::create(&target_path().join("install")) { 121 | Ok(mut install_file) => { 122 | install_file 123 | .write_all(format!("# User install script for {}\n", *APP_NAME).as_bytes()) 124 | .unwrap(); 125 | 126 | install_file.write_all(b"\n# Install executable\n").unwrap(); 127 | install_file 128 | .write_all(format!("mkdir -p {:?}\n", exe_dir).as_bytes()) 129 | .unwrap(); 130 | install_file 131 | .write_all( 132 | format!( 133 | "cp {:?} {:?}\n", 134 | target_path() 135 | .canonicalize() 136 | .unwrap() 137 | .join("release") 138 | .join(&*APP_NAME), 139 | exe_dir.join(&*APP_NAME), 140 | ) 141 | .as_bytes(), 142 | ) 143 | .unwrap(); 144 | 145 | install_file 146 | .write_all(b"\n# Install translations\n") 147 | .unwrap(); 148 | install_file 149 | .write_all(format!("mkdir -p {:?}\n", data_dir).as_bytes()) 150 | .unwrap(); 151 | install_file 152 | .write_all( 153 | format!( 154 | "cp -r {:?} {:?}\n", 155 | target_path().join("locale").canonicalize().unwrap(), 156 | data_dir, 157 | ) 158 | .as_bytes(), 159 | ) 160 | .unwrap(); 161 | 162 | install_file 163 | .write_all(b"\n# Install desktop file\n") 164 | .unwrap(); 165 | let desktop_target_dir = data_dir.join("applications"); 166 | install_file 167 | .write_all(format!("mkdir -p {:?}\n", desktop_target_dir).as_bytes()) 168 | .unwrap(); 169 | install_file 170 | .write_all( 171 | format!( 172 | "cp {:?} {:?}\n", 173 | res_path() 174 | .join(&format!("org.fengalin.{}.desktop", *APP_NAME)) 175 | .canonicalize() 176 | .unwrap(), 177 | desktop_target_dir, 178 | ) 179 | .as_bytes(), 180 | ) 181 | .unwrap(); 182 | } 183 | Err(err) => panic!("Couldn't create file `target/install`: {:?}", err), 184 | } 185 | } 186 | } 187 | 188 | // FIXME: figure out macOS conventions for icons & translations 189 | #[cfg(target_family = "unix")] 190 | fn generate_uninstall_script() { 191 | let base_dirs = BaseDirs::new().unwrap(); 192 | // Note: `base_dirs.executable_dir()` is `None` on macOS 193 | if let Some(exe_dir) = base_dirs.executable_dir() { 194 | let project_dirs = ProjectDirs::from("org", "fengalin", &APP_NAME).unwrap(); 195 | let app_data_dir = project_dirs.data_dir(); 196 | let data_dir = app_data_dir.parent().unwrap(); 197 | 198 | match File::create(&target_path().join("uninstall")) { 199 | Ok(mut install_file) => { 200 | install_file 201 | .write_all(format!("# User uninstall script for {}\n", *APP_NAME).as_bytes()) 202 | .unwrap(); 203 | 204 | install_file 205 | .write_all(b"\n# Uninstall executable\n") 206 | .unwrap(); 207 | install_file 208 | .write_all(format!("rm {:?}\n", exe_dir.join(&*APP_NAME)).as_bytes()) 209 | .unwrap(); 210 | install_file 211 | .write_all(format!("rmdir -p {:?}\n", exe_dir).as_bytes()) 212 | .unwrap(); 213 | 214 | if let Ok(mut linguas_file) = File::open(&po_path().join("LINGUAS")) { 215 | let mut linguas = String::new(); 216 | linguas_file 217 | .read_to_string(&mut linguas) 218 | .expect("Couldn't read po/LINGUAS as string"); 219 | 220 | install_file 221 | .write_all(b"\n# Uninstall translations\n") 222 | .unwrap(); 223 | let locale_base_dir = data_dir.join("locale"); 224 | for lingua in linguas.lines() { 225 | let lingua_dir = locale_base_dir.join(lingua).join("LC_MESSAGES"); 226 | install_file 227 | .write_all( 228 | format!( 229 | "rm {:?}\n", 230 | lingua_dir.join(&format!("{}.mo", *APP_NAME)), 231 | ) 232 | .as_bytes(), 233 | ) 234 | .unwrap(); 235 | 236 | install_file 237 | .write_all(format!("rmdir -p {:?}\n", lingua_dir).as_bytes()) 238 | .unwrap(); 239 | } 240 | } 241 | 242 | install_file 243 | .write_all(b"\n# Uninstall desktop file\n") 244 | .unwrap(); 245 | let desktop_target_dir = data_dir.join("applications"); 246 | install_file 247 | .write_all( 248 | format!( 249 | "rm {:?}\n", 250 | desktop_target_dir.join(&format!("org.fengalin.{}.desktop", *APP_NAME)), 251 | ) 252 | .as_bytes(), 253 | ) 254 | .unwrap(); 255 | install_file 256 | .write_all(format!("rmdir -p {:?}\n", desktop_target_dir).as_bytes()) 257 | .unwrap(); 258 | } 259 | Err(err) => panic!("Couldn't create file `target/uninstall`: {:?}", err), 260 | } 261 | } 262 | } 263 | 264 | fn main() { 265 | generate_resources(); 266 | generate_translations(); 267 | 268 | #[cfg(target_family = "unix")] 269 | generate_install_script(); 270 | 271 | #[cfg(target_family = "unix")] 272 | generate_uninstall_script(); 273 | } 274 | -------------------------------------------------------------------------------- /ci/before_install.sh: -------------------------------------------------------------------------------- 1 | set -x 2 | 3 | if [ $TRAVIS_OS_NAME = linux ]; then 4 | # Trusty uses pretty old versions => use newer 5 | 6 | # GStreamer 7 | curl -L https://people.freedesktop.org/~slomo/gstreamer.tar.gz | tar xz 8 | sed -i "s;prefix=/root/gstreamer;prefix=$PWD/gstreamer;g" $PWD/gstreamer/lib/x86_64-linux-gnu/pkgconfig/*.pc 9 | export PKG_CONFIG_PATH=$PWD/gstreamer/lib/x86_64-linux-gnu/pkgconfig 10 | export LD_LIBRARY_PATH=$PWD/gstreamer/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH 11 | elif [ $TRAVIS_OS_NAME = osx ]; then 12 | brew update 13 | brew install gtk+3 gstreamer 14 | else: 15 | echo Unknown OS $TRAVIS_OS_NAME 16 | fi 17 | 18 | set +x 19 | -------------------------------------------------------------------------------- /flatpak/org.fengalin.media-toc-player.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id" : "org.fengalin.media-toc-player", 3 | "runtime" : "org.gnome.Platform", 4 | "runtime-version" : "3.28", 5 | "sdk" : "org.gnome.Sdk", 6 | "sdk-extensions" : [ 7 | "org.freedesktop.Sdk.Extension.rust-stable" 8 | ], 9 | "command" : "media-toc-player", 10 | "finish-args" : [ 11 | "--share=ipc", 12 | "--socket=x11", 13 | "--env=GDK_BACKEND=x11", 14 | "--socket=wayland", 15 | "--socket=pulseaudio", 16 | "--talk-name=org.freedesktop.FileManager1", 17 | "--filesystem=home:ro", 18 | "--filesystem=xdg-run/dconf", 19 | "--filesystem=~/.config/dconf:ro", 20 | "--talk-name=ca.desrt.dconf", 21 | "--env=DCONF_USER_CONFIG_DIR=.config/dconf", 22 | "--env=LD_LIBRARY_PATH=/app/lib" 23 | ], 24 | "cleanup" : [ 25 | "/include", 26 | "/lib/pkgconfig", 27 | "/share/gtk-doc", 28 | "*.la" 29 | ], 30 | "modules" : [ 31 | { 32 | "name" : "gst-plugins-ugly", 33 | "buildsystem" : "meson", 34 | "sources" : [ 35 | { 36 | "type" : "git", 37 | "url" : "https://anongit.freedesktop.org/git/gstreamer/gst-plugins-ugly", 38 | "tag" : "1.14.0" 39 | } 40 | ] 41 | }, 42 | { 43 | "name" : "gst-libav", 44 | "buildsystem" : "meson", 45 | "sources" : [ 46 | { 47 | "type" : "git", 48 | "url" : "https://anongit.freedesktop.org/git/gstreamer/gst-libav", 49 | "tag" : "1.14.0" 50 | } 51 | ] 52 | }, 53 | { 54 | "name" : "media-toc-player", 55 | "buildsystem" : "simple", 56 | "build-options" : { 57 | "build-args" : [ 58 | "--share=network" 59 | ], 60 | "append-path" : "/usr/lib/sdk/rust-stable/bin", 61 | "env" : { 62 | "CARGO_HOME" : "/run/build/media-toc-player/cargo", 63 | "DEBUG" : "true", 64 | "V" : "1" 65 | } 66 | }, 67 | "build-commands" : [ 68 | "cargo build --release", 69 | "install -Dm755 target/release/media_toc_player /app/bin/media-toc-player" 70 | ], 71 | "sources" : [ 72 | { 73 | "type" : "git", 74 | "path" : ".." 75 | } 76 | ] 77 | } 78 | ], 79 | "build-options" : { 80 | "env" : { 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /po/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.gmo 3 | *.po~ 4 | POTFILES 5 | media-toc-player.pot 6 | stamp-it 7 | stamp-po 8 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | es 2 | fr 3 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | # List of source files containing translatable strings. 2 | # Please keep this file sorted alphabetically. 3 | res/ui/media-toc-player.ui 4 | src/application/command_line.rs 5 | src/application/configuration.rs 6 | src/main.rs 7 | src/media/playback_pipeline.rs 8 | src/metadata/media_info.rs 9 | src/metadata/mkvmerge_text_format.rs 10 | src/ui/chapter_tree_manager.rs 11 | src/ui/info_controller.rs 12 | src/ui/main_controller.rs 13 | src/ui/main_dispatcher.rs 14 | src/ui/streams_controller.rs 15 | -------------------------------------------------------------------------------- /po/es.po: -------------------------------------------------------------------------------- 1 | # Spanish translations for media-toc package 2 | # Traducción en español para el paquete media-toc. 3 | # Copyright (C) 2018 François Laignel 4 | # This file is distributed under the same license as the media-toc package. 5 | # Esther , 2018. 6 | # Xther , 2018. 7 | # François Laignel , 2018-2020. 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: media-toc-player\n" 12 | "Report-Msgid-Bugs-To: François Laignel \n" 13 | "POT-Creation-Date: 2020-10-20 15:38+0200\n" 14 | "PO-Revision-Date: 2020-10-17 11:49+0200\n" 15 | "Last-Translator: François Laignel \n" 16 | "Language-Team: français <>\n" 17 | "Language: fr\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 22 | "X-Generator: Gtranslator 3.38.0\n" 23 | 24 | #. Button tooltip 25 | #: res/ui/media-toc-player.ui:71 26 | msgid "Repeat current chapter" 27 | msgstr "Repetir el capítulo actual" 28 | 29 | #: res/ui/media-toc-player.ui:179 30 | msgid "Title:" 31 | msgstr "Título:" 32 | 33 | #: res/ui/media-toc-player.ui:191 34 | msgid "Artist:" 35 | msgstr "Artista:" 36 | 37 | #: res/ui/media-toc-player.ui:203 38 | msgid "Container:" 39 | msgstr "Contenedor:" 40 | 41 | #: res/ui/media-toc-player.ui:215 42 | msgid "Video Codec:" 43 | msgstr "Códec Vídeo:" 44 | 45 | #: res/ui/media-toc-player.ui:227 46 | msgid "Audio Codec:" 47 | msgstr "Códec Audio:" 48 | 49 | #: res/ui/media-toc-player.ui:239 50 | msgid "Duration:" 51 | msgstr "Duración:" 52 | 53 | #: res/ui/media-toc-player.ui:422 54 | msgid "Video Streams" 55 | msgstr "Flujos de vídeo" 56 | 57 | #: res/ui/media-toc-player.ui:455 58 | msgid "Audio Streams" 59 | msgstr "Flujos de audio" 60 | 61 | #: res/ui/media-toc-player.ui:488 62 | msgid "Text Streams" 63 | msgstr "Flujos de texto" 64 | 65 | #: res/ui/media-toc-player.ui:620 66 | msgid "Info text" 67 | msgstr "" 68 | 69 | #. Button tooltip 70 | #: res/ui/media-toc-player.ui:714 71 | msgid "Play / Pause" 72 | msgstr "Reproducción / Pausa" 73 | 74 | #. Button tooltip 75 | #: res/ui/media-toc-player.ui:787 76 | msgid "Perspective selector" 77 | msgstr "Selector de perspectiva" 78 | 79 | #. Button tooltip 80 | #: res/ui/media-toc-player.ui:828 81 | msgid "Open media" 82 | msgstr "Abrir un archivo multimedia" 83 | 84 | #. Button tooltip 85 | #: res/ui/media-toc-player.ui:848 86 | msgid "Show / Hide chapters list" 87 | msgstr "Mostrar / ocultar la lista de capítulos" 88 | 89 | #. Name of the perspective 90 | #: res/ui/media-toc-player.ui:900 91 | msgid "Display" 92 | msgstr "Visualización" 93 | 94 | #. Name of the perspective 95 | #: res/ui/media-toc-player.ui:961 96 | msgid "Streams" 97 | msgstr "Flujos" 98 | 99 | #: src/application/command_line.rs:12 src/ui/main_controller.rs:160 100 | msgid "A media player with a table of contents" 101 | msgstr "Un reproductor multimedia con una tabla de contenidos" 102 | 103 | #: src/application/command_line.rs:13 104 | msgid "Display this message" 105 | msgstr "Muestra este mensaje" 106 | 107 | #: src/application/command_line.rs:14 108 | msgid "Print version information" 109 | msgstr "Muestra la versión" 110 | 111 | #: src/application/command_line.rs:17 112 | msgid "MEDIA" 113 | msgstr "ARCHIVO MULTIMEDIA" 114 | 115 | #: src/application/command_line.rs:29 116 | msgid "Disable video rendering hardware acceleration" 117 | msgstr "Desactiva la aceleración de hardware de reproducción de video " 118 | 119 | #: src/application/command_line.rs:33 120 | msgid "Path to the input media file" 121 | msgstr "Ruta al archivo multimedia" 122 | 123 | #: src/application/configuration.rs:67 124 | msgid "couldn't load configuration: {}" 125 | msgstr "No se pudo cargar la configuración: {}" 126 | 127 | #: src/application/configuration.rs:104 128 | msgid "couldn't write configuration: {}" 129 | msgstr "No se pudo escribir la configuración: {}" 130 | 131 | #: src/application/configuration.rs:115 132 | msgid "couldn't serialize configuration: {}" 133 | msgstr "No se pudo serializar la configuración: {}" 134 | 135 | #: src/application/configuration.rs:127 136 | msgid "couldn't create configuration file: {}" 137 | msgstr "No se pudo crear el archivo de configuración: {}" 138 | 139 | #: src/main.rs:21 140 | msgid "Failed to initialize GTK" 141 | msgstr "No se pudo inicializar GTK" 142 | 143 | #: src/media/playback_pipeline.rs:189 144 | msgid "Opening {}..." 145 | msgstr "Abriendo {}..." 146 | 147 | #: src/media/playback_pipeline.rs:211 148 | msgid "" 149 | "Missing `decodebin3`\n" 150 | "check your gst-plugins-base install" 151 | msgstr "" 152 | "Falta `decodebin3`\n" 153 | "Verifique la instalación de gst-plugins-base" 154 | 155 | #: src/media/playback_pipeline.rs:226 156 | msgid "Couldn't find GStreamer GTK video sink." 157 | msgstr "No se pudo encontrar el elemento \"GTK video sink\"" 158 | 159 | #: src/media/playback_pipeline.rs:227 160 | msgid "Video playback will be disabled." 161 | msgstr "La reproducción de vídeo estará deshabilitada." 162 | 163 | #: src/media/playback_pipeline.rs:228 164 | msgid "Please install {} or {}, depending on your distribution." 165 | msgstr "Por favor installe {} o {} en función de su distribución." 166 | 167 | #: src/media/playback_pipeline.rs:372 168 | msgid "Missing plugin: {}" 169 | msgstr "No se encontró el plugin: {}" 170 | 171 | #: src/metadata/media_info.rs:35 172 | msgid "untitled" 173 | msgstr "sin título" 174 | 175 | #: src/metadata/mkvmerge_text_format.rs:126 176 | msgid "unexpected error reading mkvmerge text file." 177 | msgstr "error inesperado al leer el fichero de texto mkvmerge" 178 | 179 | #: src/metadata/mkvmerge_text_format.rs:144 180 | #: src/metadata/mkvmerge_text_format.rs:161 181 | msgid "unexpected sequence starting with: {}" 182 | msgstr "secuencia inesperada empezando con: {}" 183 | 184 | #: src/metadata/mkvmerge_text_format.rs:157 185 | msgid "expecting a number, found: {}" 186 | msgstr "se esperaba un número en lugar de {}" 187 | 188 | #: src/metadata/mkvmerge_text_format.rs:159 189 | msgid "chapter numbers don't match for: {}" 190 | msgstr "los números de capítulo no corresponden para: {}" 191 | 192 | #: src/metadata/mkvmerge_text_format.rs:199 193 | msgid "couldn't update last start position" 194 | msgstr "no se puede actualizar la última posición de inicio" 195 | 196 | #: src/ui/chapter_tree_manager.rs:369 197 | msgid "Title" 198 | msgstr "Título" 199 | 200 | #: src/ui/chapter_tree_manager.rs:375 201 | msgid "Start" 202 | msgstr "Inicio" 203 | 204 | #: src/ui/chapter_tree_manager.rs:379 205 | msgid "End" 206 | msgstr "Fín" 207 | 208 | #: src/ui/info_controller.rs:103 209 | msgid "No toc in file \"{}\"" 210 | msgstr "Ninguna tabla de contenido en el archivo \"{}\"" 211 | 212 | #: src/ui/info_controller.rs:114 213 | msgid "" 214 | "Error opening toc file \"{}\":\n" 215 | "{}" 216 | msgstr "" 217 | "Error al abrir el fichero \"{}\":\n" 218 | "{}" 219 | 220 | #: src/ui/info_controller.rs:128 221 | msgid "Failed to open toc file." 222 | msgstr "No se pudo abrir el archivo de la tabla de contenidos." 223 | 224 | #: src/ui/main_controller.rs:70 225 | msgid "media-toc player" 226 | msgstr "reproductor media-toc" 227 | 228 | #: src/ui/main_controller.rs:75 229 | msgid "Open a media file" 230 | msgstr "Abrir un archivo multimedia" 231 | 232 | #: src/ui/main_controller.rs:78 233 | msgid "Open" 234 | msgstr "Abrir" 235 | 236 | #: src/ui/main_controller.rs:79 237 | msgid "Cancel" 238 | msgstr "Anular" 239 | 240 | #: src/ui/main_controller.rs:162 241 | msgid "translator-credits" 242 | msgstr "Xther" 243 | 244 | #: src/ui/main_controller.rs:166 245 | msgid "Learn more about media-toc-player" 246 | msgstr "Más información acerca de media-toc-player" 247 | 248 | #: src/ui/main_controller.rs:375 249 | msgid "Some streams are not usable. {}" 250 | msgstr "No se pueden utilizar algunos flujos. {}" 251 | 252 | #: src/ui/main_controller.rs:396 253 | msgid "An unrecoverable error occured. {}" 254 | msgstr "Se encontró un error irrecuperable. {}" 255 | 256 | #: src/ui/main_controller.rs:423 257 | msgid "Failed to switch the media to Paused" 258 | msgstr "No se pudo pasar en modo pausa." 259 | 260 | #: src/ui/main_controller.rs:430 261 | msgid "" 262 | "Video rendering hardware acceleration seems broken and has been disabled.\n" 263 | "Please restart the application." 264 | msgstr "" 265 | "La aceleración de hardware de reproducción de video ha sido desactivada ya " 266 | "que no parece funcionar correctamente.\n" 267 | "Reinicie la aplicación por favor." 268 | 269 | #: src/ui/main_controller.rs:436 270 | msgid "Error opening file. {}" 271 | msgstr "Error al abrir el archivo. {}" 272 | 273 | #: src/ui/main_controller.rs:443 274 | msgid "" 275 | "Missing plugin:\n" 276 | "{}" 277 | msgid_plural "" 278 | "Missing plugins:\n" 279 | "{}" 280 | msgstr[0] "" 281 | "No se encontró el plugin:\n" 282 | "{}" 283 | msgstr[1] "" 284 | "No se encontraron los plugins:\n" 285 | "{}" 286 | 287 | #: src/ui/main_dispatcher.rs:77 288 | msgid "About" 289 | msgstr "Acerca de" 290 | 291 | #: src/ui/main_dispatcher.rs:88 292 | msgid "Quit" 293 | msgstr "Salir" 294 | 295 | #: src/ui/main_dispatcher.rs:132 296 | msgid "Open media file" 297 | msgstr "Abrir el archivo multimedia" 298 | 299 | #: src/ui/main_dispatcher.rs:154 300 | msgid "Failed to initialize GStreamer, the application can't be used." 301 | msgstr "" 302 | "No se pudo inicializar GStreamer, la aplicación no puede ser utilizada." 303 | 304 | #: src/ui/streams_controller.rs:108 305 | msgid "unknown" 306 | msgstr "desconocido" 307 | 308 | #: src/ui/streams_controller.rs:201 src/ui/streams_controller.rs:259 309 | #: src/ui/streams_controller.rs:309 310 | msgid "Stream id" 311 | msgstr "Id. del flujo" 312 | 313 | #: src/ui/streams_controller.rs:208 src/ui/streams_controller.rs:266 314 | #: src/ui/streams_controller.rs:316 315 | msgid "Language" 316 | msgstr "Idioma" 317 | 318 | #: src/ui/streams_controller.rs:213 src/ui/streams_controller.rs:271 319 | #: src/ui/streams_controller.rs:321 320 | msgid "Codec" 321 | msgstr "Códec" 322 | 323 | #: src/ui/streams_controller.rs:216 324 | msgid "Width" 325 | msgstr "Ancho" 326 | 327 | #: src/ui/streams_controller.rs:223 328 | msgid "Height" 329 | msgstr "Alto" 330 | 331 | #: src/ui/streams_controller.rs:228 src/ui/streams_controller.rs:286 332 | #: src/ui/streams_controller.rs:329 333 | msgid "Comment" 334 | msgstr "Comentario" 335 | 336 | #: src/ui/streams_controller.rs:274 337 | msgid "Rate" 338 | msgstr "Velocidad" 339 | 340 | #: src/ui/streams_controller.rs:281 341 | msgid "Channels" 342 | msgstr "Canales" 343 | 344 | #: src/ui/streams_controller.rs:324 345 | msgid "Format" 346 | msgstr "Formato" 347 | 348 | #~ msgid "Could not set media in Playing mode" 349 | #~ msgstr "No se pudo pasar en modo reproducción." 350 | -------------------------------------------------------------------------------- /po/fr.po: -------------------------------------------------------------------------------- 1 | # French translations for media-toc-player package 2 | # Traductions françaises du paquet media-toc-player. 3 | # Copyright (C) 2018 François Laignel 4 | # This file is distributed under the same license as the media-toc-player package. 5 | # François Laignel , 2018-2020. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: media-toc-player\n" 10 | "Report-Msgid-Bugs-To: François Laignel \n" 11 | "POT-Creation-Date: 2020-10-20 15:37+0200\n" 12 | "PO-Revision-Date: 2020-10-17 11:45+0200\n" 13 | "Last-Translator: François Laignel \n" 14 | "Language-Team: français <>\n" 15 | "Language: fr\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | "X-Generator: Gtranslator 3.38.0\n" 21 | 22 | #. Button tooltip 23 | #: res/ui/media-toc-player.ui:71 24 | msgid "Repeat current chapter" 25 | msgstr "Répéter le chapitre courant" 26 | 27 | #: res/ui/media-toc-player.ui:179 28 | msgid "Title:" 29 | msgstr "Titre :" 30 | 31 | #: res/ui/media-toc-player.ui:191 32 | msgid "Artist:" 33 | msgstr "Artiste :" 34 | 35 | #: res/ui/media-toc-player.ui:203 36 | msgid "Container:" 37 | msgstr "Conteneur :" 38 | 39 | #: res/ui/media-toc-player.ui:215 40 | msgid "Video Codec:" 41 | msgstr "Codec Vidéo :" 42 | 43 | #: res/ui/media-toc-player.ui:227 44 | msgid "Audio Codec:" 45 | msgstr "Codec Audio :" 46 | 47 | #: res/ui/media-toc-player.ui:239 48 | msgid "Duration:" 49 | msgstr "Durée :" 50 | 51 | #: res/ui/media-toc-player.ui:422 52 | msgid "Video Streams" 53 | msgstr "Flux vidéos" 54 | 55 | #: res/ui/media-toc-player.ui:455 56 | msgid "Audio Streams" 57 | msgstr "Flux Audios" 58 | 59 | #: res/ui/media-toc-player.ui:488 60 | msgid "Text Streams" 61 | msgstr "Flux Textes" 62 | 63 | #: res/ui/media-toc-player.ui:620 64 | msgid "Info text" 65 | msgstr "Message d'information" 66 | 67 | #. Button tooltip 68 | #: res/ui/media-toc-player.ui:714 69 | msgid "Play / Pause" 70 | msgstr "Jouer / Mettre en pause" 71 | 72 | #. Button tooltip 73 | #: res/ui/media-toc-player.ui:787 74 | msgid "Perspective selector" 75 | msgstr "Sélecteur de perspective" 76 | 77 | #. Button tooltip 78 | #: res/ui/media-toc-player.ui:828 79 | msgid "Open media" 80 | msgstr "Ouvrir un fichier multimédia" 81 | 82 | #. Button tooltip 83 | #: res/ui/media-toc-player.ui:848 84 | msgid "Show / Hide chapters list" 85 | msgstr "Afficher / Cacher la liste chapitres" 86 | 87 | #. Name of the perspective 88 | #: res/ui/media-toc-player.ui:900 89 | msgid "Display" 90 | msgstr "Affichage" 91 | 92 | #. Name of the perspective 93 | #: res/ui/media-toc-player.ui:961 94 | msgid "Streams" 95 | msgstr "Flux" 96 | 97 | #: src/application/command_line.rs:12 src/ui/main_controller.rs:160 98 | msgid "A media player with a table of contents" 99 | msgstr "Un lecteur multimédia muni d'une table des matières" 100 | 101 | #: src/application/command_line.rs:13 102 | msgid "Display this message" 103 | msgstr "Affiche ce message" 104 | 105 | #: src/application/command_line.rs:14 106 | msgid "Print version information" 107 | msgstr "Affiche la version" 108 | 109 | #: src/application/command_line.rs:17 110 | msgid "MEDIA" 111 | msgstr "MÉDIA" 112 | 113 | #: src/application/command_line.rs:29 114 | msgid "Disable video rendering hardware acceleration" 115 | msgstr "Désactive l'accélération matérielle pour la vidéo" 116 | 117 | #: src/application/command_line.rs:33 118 | msgid "Path to the input media file" 119 | msgstr "Chemin du fichier multimédia" 120 | 121 | #: src/application/configuration.rs:67 122 | msgid "couldn't load configuration: {}" 123 | msgstr "impossible de charger la configuration : {}" 124 | 125 | #: src/application/configuration.rs:104 126 | msgid "couldn't write configuration: {}" 127 | msgstr "impossible d'écrire la configuration : {}" 128 | 129 | #: src/application/configuration.rs:115 130 | msgid "couldn't serialize configuration: {}" 131 | msgstr "impossible de sérialiser la configuration : {}" 132 | 133 | #: src/application/configuration.rs:127 134 | msgid "couldn't create configuration file: {}" 135 | msgstr "impossible de créer le fichier de configuration : {}" 136 | 137 | #: src/main.rs:21 138 | msgid "Failed to initialize GTK" 139 | msgstr "Impossible d'intialiser GTK" 140 | 141 | #: src/media/playback_pipeline.rs:189 142 | msgid "Opening {}..." 143 | msgstr "Ouverture de {}…" 144 | 145 | #: src/media/playback_pipeline.rs:211 146 | msgid "" 147 | "Missing `decodebin3`\n" 148 | "check your gst-plugins-base install" 149 | msgstr "" 150 | "`decodebin3` manquant\n" 151 | "Vérifiez l'installation de gst-plugin-base" 152 | 153 | #: src/media/playback_pipeline.rs:226 154 | msgid "Couldn't find GStreamer GTK video sink." 155 | msgstr "Impossible de trouver l'élément « GTK video sink »." 156 | 157 | #: src/media/playback_pipeline.rs:227 158 | msgid "Video playback will be disabled." 159 | msgstr "L'affichage vidéo sera désactivé." 160 | 161 | #: src/media/playback_pipeline.rs:228 162 | msgid "Please install {} or {}, depending on your distribution." 163 | msgstr "Veuillez installer {} ou {}, selon votre distribution." 164 | 165 | #: src/media/playback_pipeline.rs:372 166 | msgid "Missing plugin: {}" 167 | msgstr "Greffon manquant : {}" 168 | 169 | #: src/metadata/media_info.rs:35 170 | msgid "untitled" 171 | msgstr "sans titre" 172 | 173 | #: src/metadata/mkvmerge_text_format.rs:126 174 | msgid "unexpected error reading mkvmerge text file." 175 | msgstr "erreur inattendue à la lecture du fichier texte mkvmerge" 176 | 177 | #: src/metadata/mkvmerge_text_format.rs:144 178 | #: src/metadata/mkvmerge_text_format.rs:161 179 | msgid "unexpected sequence starting with: {}" 180 | msgstr "séquence inattendue commençant par : {}" 181 | 182 | #: src/metadata/mkvmerge_text_format.rs:157 183 | msgid "expecting a number, found: {}" 184 | msgstr "un nombre est attendu au lieu de : {}" 185 | 186 | #: src/metadata/mkvmerge_text_format.rs:159 187 | msgid "chapter numbers don't match for: {}" 188 | msgstr "les numéros de chapitre ne correspondent pas pour : {}" 189 | 190 | #: src/metadata/mkvmerge_text_format.rs:199 191 | msgid "couldn't update last start position" 192 | msgstr "impossible de mettre à jour la dernière position de départ" 193 | 194 | #: src/ui/chapter_tree_manager.rs:369 195 | msgid "Title" 196 | msgstr "Titre" 197 | 198 | #: src/ui/chapter_tree_manager.rs:375 199 | msgid "Start" 200 | msgstr "Début" 201 | 202 | #: src/ui/chapter_tree_manager.rs:379 203 | msgid "End" 204 | msgstr "Fin" 205 | 206 | #: src/ui/info_controller.rs:103 207 | msgid "No toc in file \"{}\"" 208 | msgstr "Aucune table des matières dans le fichier «{}»" 209 | 210 | #: src/ui/info_controller.rs:114 211 | msgid "" 212 | "Error opening toc file \"{}\":\n" 213 | "{}" 214 | msgstr "" 215 | "Erreur à l'ouverture du fichier «{}» :\n" 216 | "{}" 217 | 218 | #: src/ui/info_controller.rs:128 219 | msgid "Failed to open toc file." 220 | msgstr "Impossible d'ouvrir le fichier de table des matières." 221 | 222 | #: src/ui/main_controller.rs:70 223 | msgid "media-toc player" 224 | msgstr "lecteur media-toc" 225 | 226 | #: src/ui/main_controller.rs:75 227 | msgid "Open a media file" 228 | msgstr "Ouvrir un fichier multimédia" 229 | 230 | #: src/ui/main_controller.rs:78 231 | msgid "Open" 232 | msgstr "Ouvrir" 233 | 234 | #: src/ui/main_controller.rs:79 235 | msgid "Cancel" 236 | msgstr "Annuler" 237 | 238 | #: src/ui/main_controller.rs:162 239 | msgid "translator-credits" 240 | msgstr "François Laignel" 241 | 242 | #: src/ui/main_controller.rs:166 243 | msgid "Learn more about media-toc-player" 244 | msgstr "En apprendre plus sur media-toc-player" 245 | 246 | #: src/ui/main_controller.rs:375 247 | msgid "Some streams are not usable. {}" 248 | msgstr "Certains flux ne sont pas utilisables. {}" 249 | 250 | #: src/ui/main_controller.rs:396 251 | msgid "An unrecoverable error occured. {}" 252 | msgstr "Une erreur irrécupérable s'est produite. {}" 253 | 254 | #: src/ui/main_controller.rs:423 255 | msgid "Failed to switch the media to Paused" 256 | msgstr "Èchec lors du passage du média en pause." 257 | 258 | #: src/ui/main_controller.rs:430 259 | msgid "" 260 | "Video rendering hardware acceleration seems broken and has been disabled.\n" 261 | "Please restart the application." 262 | msgstr "" 263 | "L'accélération matérielle pour la vidéo a été désactivée\n" 264 | "car elle ne semble pas fonctionner correctement.\n" 265 | "Veuillez relancer l'application." 266 | 267 | #: src/ui/main_controller.rs:436 268 | msgid "Error opening file. {}" 269 | msgstr "Erreur à l'ouverture du fichier. {}" 270 | 271 | #: src/ui/main_controller.rs:443 272 | msgid "" 273 | "Missing plugin:\n" 274 | "{}" 275 | msgid_plural "" 276 | "Missing plugins:\n" 277 | "{}" 278 | msgstr[0] "" 279 | "Greffon manquant :\n" 280 | "{}" 281 | msgstr[1] "" 282 | "Greffons manquants :\n" 283 | "{}" 284 | 285 | #: src/ui/main_dispatcher.rs:77 286 | msgid "About" 287 | msgstr "À propos" 288 | 289 | #: src/ui/main_dispatcher.rs:88 290 | msgid "Quit" 291 | msgstr "Quitter" 292 | 293 | #: src/ui/main_dispatcher.rs:132 294 | msgid "Open media file" 295 | msgstr "Ouvrir un fichier multimédia" 296 | 297 | #: src/ui/main_dispatcher.rs:154 298 | msgid "Failed to initialize GStreamer, the application can't be used." 299 | msgstr "Échec à l'initialisation de GStreamer, l'application est inutilisable." 300 | 301 | #: src/ui/streams_controller.rs:108 302 | msgid "unknown" 303 | msgstr "inconnu" 304 | 305 | #: src/ui/streams_controller.rs:201 src/ui/streams_controller.rs:259 306 | #: src/ui/streams_controller.rs:309 307 | msgid "Stream id" 308 | msgstr "Id. du flux" 309 | 310 | #: src/ui/streams_controller.rs:208 src/ui/streams_controller.rs:266 311 | #: src/ui/streams_controller.rs:316 312 | msgid "Language" 313 | msgstr "Langue" 314 | 315 | #: src/ui/streams_controller.rs:213 src/ui/streams_controller.rs:271 316 | #: src/ui/streams_controller.rs:321 317 | msgid "Codec" 318 | msgstr "Codec" 319 | 320 | #: src/ui/streams_controller.rs:216 321 | msgid "Width" 322 | msgstr "Largeur" 323 | 324 | #: src/ui/streams_controller.rs:223 325 | msgid "Height" 326 | msgstr "Hauteur" 327 | 328 | #: src/ui/streams_controller.rs:228 src/ui/streams_controller.rs:286 329 | #: src/ui/streams_controller.rs:329 330 | msgid "Comment" 331 | msgstr "Commentaire" 332 | 333 | #: src/ui/streams_controller.rs:274 334 | msgid "Rate" 335 | msgstr "Débit" 336 | 337 | #: src/ui/streams_controller.rs:281 338 | msgid "Channels" 339 | msgstr "Canaux" 340 | 341 | #: src/ui/streams_controller.rs:324 342 | msgid "Format" 343 | msgstr "Format" 344 | 345 | #~ msgid "Could not set media in Paused mode" 346 | #~ msgstr "Impossible de passer le média en mode pause." 347 | 348 | #~ msgid "Could not set media in Playing mode" 349 | #~ msgstr "Impossible de passer le média en mode lecture." 350 | -------------------------------------------------------------------------------- /po/update: -------------------------------------------------------------------------------- 1 | PROJECT=media-toc-player 2 | 3 | # Update the pot file 4 | xgettext --files-from=po/POTFILES.in --directory=. --from-code=UTF-8 \ 5 | --default-domain=$PROJECT --output-dir=po --sort-by-file \ 6 | --package-name=$PROJECT -o $PROJECT.pot \ 7 | --copyright-holder='François Laignel' \ 8 | --msgid-bugs-address='François Laignel ' 9 | 10 | if [ -n "$1" ]; then 11 | # Merge or create messages for the specified lang 12 | if [ -f po/$1.po ]; then 13 | msgmerge --directory=po --sort-by-file -o po/$1.po $1.po $PROJECT.pot; 14 | else 15 | msginit -o po/$1.po -i po/$PROJECT.pot; 16 | fi 17 | fi 18 | -------------------------------------------------------------------------------- /res/org.fengalin.media-toc-player.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=media-toc-player 3 | Comment=A media player with a table of contents. 4 | Comment[es]=Un reproductor multimedia con una tabla de contenidos. 5 | Comment[fr]=Un lecteur multimédia muni d'une table des matières. 6 | GenericName=Media Player 7 | Exec=media-toc-player 8 | Icon=org.fengalin.media-toc 9 | Type=Application 10 | StartupNotify=true 11 | X-GNOME-UsesNotifications=true 12 | Categories=GStreamer;GTK;Multimedia; 13 | Keywords=Multimedia;table of contents; 14 | Keywords[es]=Multimedia;tabla de contenido; 15 | Keywords[fr]=Multimédia;table des matières; 16 | -------------------------------------------------------------------------------- /res/ui/ui.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | media-toc-player.ui 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/application/command_line.rs: -------------------------------------------------------------------------------- 1 | use clap::{App, Arg}; 2 | use gettextrs::gettext; 3 | 4 | use std::path::PathBuf; 5 | 6 | pub struct CommandLineArguments { 7 | pub input_file: Option, 8 | pub disable_gl: bool, 9 | } 10 | 11 | pub fn get_command_line() -> CommandLineArguments { 12 | let about_msg = gettext("A media player with a table of contents"); 13 | let help_msg = gettext("Display this message"); 14 | let version_msg = gettext("Print version information"); 15 | 16 | let disable_gl_arg = "DISABLE_GL"; 17 | let input_arg = gettext("MEDIA"); 18 | 19 | let matches = App::new(env!("CARGO_PKG_NAME")) 20 | .version(env!("CARGO_PKG_VERSION")) 21 | .author(env!("CARGO_PKG_AUTHORS")) 22 | .about(&about_msg[..]) 23 | .help_message(&help_msg[..]) 24 | .version_message(&version_msg[..]) 25 | .arg( 26 | Arg::with_name(&disable_gl_arg[..]) 27 | .short("d") 28 | .long("disable-gl") 29 | .help(&gettext("Disable video rendering hardware acceleration")), 30 | ) 31 | .arg( 32 | Arg::with_name(&input_arg[..]) 33 | .help(&gettext("Path to the input media file")) 34 | .last(false), 35 | ) 36 | .get_matches(); 37 | 38 | CommandLineArguments { 39 | input_file: matches 40 | .value_of(input_arg.as_str()) 41 | .map(|input_file| input_file.into()), 42 | disable_gl: matches.is_present(disable_gl_arg), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/application/configuration.rs: -------------------------------------------------------------------------------- 1 | use directories::ProjectDirs; 2 | use gettextrs::gettext; 3 | use lazy_static::lazy_static; 4 | use log::{debug, error}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use std::{ 8 | fs::{create_dir_all, File}, 9 | io::Write, 10 | ops::{Deref, DerefMut}, 11 | path::PathBuf, 12 | sync::RwLock, 13 | }; 14 | 15 | use super::{APP_NAME, SLD, TLD}; 16 | 17 | const CONFIG_FILENAME: &str = "config.ron"; 18 | 19 | lazy_static! { 20 | pub static ref CONFIG: RwLock = RwLock::new(GlobalConfig::new()); 21 | } 22 | 23 | #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] 24 | pub struct UI { 25 | pub width: i32, 26 | pub height: i32, 27 | pub is_chapters_list_hidden: bool, 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] 31 | pub struct Media { 32 | pub is_gl_disabled: bool, 33 | pub last_path: Option, 34 | } 35 | 36 | #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] 37 | pub struct Config { 38 | pub ui: UI, 39 | pub media: Media, 40 | } 41 | 42 | pub struct GlobalConfig { 43 | path: PathBuf, 44 | last: Config, 45 | current: Config, 46 | } 47 | 48 | impl GlobalConfig { 49 | fn new() -> GlobalConfig { 50 | let project_dirs = ProjectDirs::from(TLD, SLD, &APP_NAME) 51 | .expect("Couldn't find project dirs for this platform"); 52 | let config_dir = project_dirs.config_dir(); 53 | create_dir_all(&config_dir).unwrap(); 54 | let path = config_dir.join(CONFIG_FILENAME); 55 | 56 | let last = match File::open(&path) { 57 | Ok(config_file) => { 58 | let config: Result = ron::de::from_reader(config_file); 59 | match config { 60 | Ok(config) => { 61 | debug!("read config: {:?}", config); 62 | config 63 | } 64 | Err(err) => { 65 | error!( 66 | "{}", 67 | &gettext("couldn't load configuration: {}").replacen( 68 | "{}", 69 | &format!("{:?}", err), 70 | 1 71 | ), 72 | ); 73 | Config::default() 74 | } 75 | } 76 | } 77 | Err(_) => Config::default(), 78 | }; 79 | 80 | GlobalConfig { 81 | path, 82 | current: last.clone(), 83 | last, 84 | } 85 | } 86 | 87 | pub fn save(&mut self) { 88 | if self.last == self.current { 89 | // unchanged => don't save 90 | return; 91 | } 92 | 93 | match File::create(&self.path) { 94 | Ok(mut config_file) => { 95 | match ron::ser::to_string_pretty(&self.current, ron::ser::PrettyConfig::default()) { 96 | Ok(config_str) => match config_file.write_all(config_str.as_bytes()) { 97 | Ok(()) => { 98 | self.last = self.current.clone(); 99 | debug!("saved config: {:?}", self.current); 100 | } 101 | Err(err) => { 102 | error!( 103 | "{}", 104 | &gettext("couldn't write configuration: {}").replacen( 105 | "{}", 106 | &format!("{:?}", err), 107 | 1 108 | ), 109 | ); 110 | } 111 | }, 112 | Err(err) => { 113 | error!( 114 | "{}", 115 | &gettext("couldn't serialize configuration: {}").replacen( 116 | "{}", 117 | &format!("{:?}", err), 118 | 1 119 | ), 120 | ); 121 | } 122 | } 123 | } 124 | Err(err) => { 125 | error!( 126 | "{}", 127 | &gettext("couldn't create configuration file: {}").replacen( 128 | "{}", 129 | &format!("{:?}", err), 130 | 1 131 | ), 132 | ); 133 | } 134 | } 135 | } 136 | } 137 | 138 | impl Deref for GlobalConfig { 139 | type Target = Config; 140 | 141 | fn deref(&self) -> &Self::Target { 142 | &self.current 143 | } 144 | } 145 | 146 | impl DerefMut for GlobalConfig { 147 | fn deref_mut(&mut self) -> &mut Self::Target { 148 | &mut self.current 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/application/locale.rs: -------------------------------------------------------------------------------- 1 | use directories::ProjectDirs; 2 | use gettextrs::{TextDomain, TextDomainError}; 3 | use log::{error, info, warn}; 4 | 5 | use super::{APP_NAME, SLD, TLD}; 6 | 7 | pub fn init_locale() { 8 | // Search translations under `target` first 9 | // in order to reflect latest changes during development 10 | let text_domain = TextDomain::new(&*APP_NAME) 11 | .codeset("UTF-8") 12 | .prepend("target"); 13 | 14 | // Add user's data dir in the search path 15 | let project_dirs = ProjectDirs::from(TLD, SLD, &APP_NAME) 16 | .expect("Couldn't find project dirs for this platform"); 17 | let _app_data_dir = project_dirs.data_dir(); 18 | 19 | // FIXME: figure out macOS conventions 20 | #[cfg(all(target_family = "unix", not(target_os = "macos")))] 21 | let text_domain = match _app_data_dir.parent() { 22 | Some(data_dir) => text_domain.prepend(data_dir), 23 | None => text_domain, 24 | }; 25 | 26 | #[cfg(target_os = "windows")] 27 | let text_domain = text_domain.prepend(_app_data_dir); 28 | 29 | match text_domain.init() { 30 | Ok(locale) => info!("Translation found, `setlocale` returned {:?}", locale), 31 | Err(TextDomainError::TranslationNotFound(lang)) => { 32 | warn!("Translation not found for language {}", lang) 33 | } 34 | Err(TextDomainError::InvalidLocale(locale)) => error!("Invalid locale {}", locale), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/application/mod.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | 3 | pub const TLD: &str = "org"; 4 | pub const SLD: &str = "fengalin"; 5 | 6 | lazy_static! { 7 | pub static ref APP_NAME: String = env!("CARGO_PKG_NAME").to_string(); 8 | } 9 | 10 | lazy_static! { 11 | pub static ref APP_ID: String = format!("{}.{}.{}", TLD, SLD, *APP_NAME); 12 | } 13 | 14 | lazy_static! { 15 | pub static ref APP_PATH: String = format!("/{}/{}/{}", TLD, SLD, *APP_NAME); 16 | } 17 | 18 | mod command_line; 19 | pub use self::command_line::{get_command_line, CommandLineArguments}; 20 | 21 | mod configuration; 22 | pub use self::configuration::CONFIG; 23 | 24 | mod locale; 25 | pub use self::locale::init_locale; 26 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use log::error; 3 | 4 | mod application; 5 | use application::{get_command_line, init_locale}; 6 | mod media; 7 | mod metadata; 8 | mod ui; 9 | 10 | fn main() { 11 | env_logger::init(); 12 | 13 | init_locale(); 14 | 15 | // Character encoding is broken unless gtk (glib) is initialized 16 | let is_gtk_ok = gtk::init().is_ok(); 17 | 18 | if is_gtk_ok { 19 | ui::run(get_command_line()); 20 | } else { 21 | error!("{}", gettext("Failed to initialize GTK")); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/media/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod playback_pipeline; 2 | pub use self::playback_pipeline::{ 3 | MediaMessage, MissingPlugins, OpenError, PlaybackPipeline, SeekError, SelectStreamsError, 4 | StateChangeError, 5 | }; 6 | 7 | pub mod timestamp; 8 | pub use self::timestamp::Timestamp; 9 | -------------------------------------------------------------------------------- /src/media/timestamp.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Ordering, 3 | fmt, 4 | ops::{Add, Sub}, 5 | }; 6 | 7 | use crate::metadata::{Duration, Timestamp4Humans}; 8 | 9 | #[derive(Clone, Copy, Default, Debug, Eq, Ord, PartialEq, PartialOrd)] 10 | pub struct Timestamp(u64); 11 | 12 | impl Timestamp { 13 | pub fn new(value: u64) -> Self { 14 | Timestamp(value) 15 | } 16 | 17 | pub fn for_humans(self) -> Timestamp4Humans { 18 | Timestamp4Humans::from_nano(self.0) 19 | } 20 | 21 | pub fn as_f64(self) -> f64 { 22 | self.0 as f64 23 | } 24 | 25 | pub fn as_u64(self) -> u64 { 26 | self.0 27 | } 28 | 29 | pub fn saturating_sub(self, rhs: Duration) -> Self { 30 | Timestamp(self.0.saturating_sub(rhs.as_u64())) 31 | } 32 | } 33 | 34 | impl From for Timestamp { 35 | fn from(value: u64) -> Self { 36 | Self(value) 37 | } 38 | } 39 | 40 | impl From for Timestamp { 41 | fn from(value: i64) -> Self { 42 | Self(value as u64) 43 | } 44 | } 45 | 46 | impl From for Timestamp { 47 | fn from(duration: Duration) -> Self { 48 | Self(duration.as_u64()) 49 | } 50 | } 51 | 52 | impl Sub for Timestamp { 53 | type Output = Duration; 54 | 55 | fn sub(self, rhs: Timestamp) -> Duration { 56 | Duration::from_nanos(self.0 - rhs.0) 57 | } 58 | } 59 | 60 | impl Add for Timestamp { 61 | type Output = Timestamp; 62 | 63 | fn add(self, rhs: Duration) -> Timestamp { 64 | Timestamp(self.0 + rhs.as_u64()) 65 | } 66 | } 67 | 68 | impl Sub for Timestamp { 69 | type Output = Timestamp; 70 | 71 | fn sub(self, rhs: Duration) -> Timestamp { 72 | Timestamp(self.0 - rhs.as_u64()) 73 | } 74 | } 75 | 76 | impl PartialOrd for Timestamp { 77 | fn partial_cmp(&self, rhs: &Duration) -> Option { 78 | Some(self.0.cmp(&rhs.as_u64())) 79 | } 80 | } 81 | 82 | impl PartialEq for Timestamp { 83 | fn eq(&self, rhs: &Duration) -> bool { 84 | self.0 == rhs.as_u64() 85 | } 86 | } 87 | 88 | impl fmt::Display for Timestamp { 89 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 90 | write!(f, "ts {}", self.0) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/metadata/duration.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, 3 | ops::{Div, DivAssign, Mul, MulAssign}, 4 | }; 5 | 6 | // FIXME: consider moving to std::time::Duration when `div_duration` is stabilized. 7 | 8 | #[derive(Clone, Copy, Default, Debug, Eq, Ord, PartialEq, PartialOrd)] 9 | pub struct Duration(u64); 10 | 11 | impl Duration { 12 | pub const fn from_nanos(nanos: u64) -> Self { 13 | Duration(nanos) 14 | } 15 | 16 | pub const fn from_secs(secs: u64) -> Self { 17 | Duration(secs * 1_000_000_000u64) 18 | } 19 | 20 | pub fn as_f64(self) -> f64 { 21 | self.0 as f64 22 | } 23 | 24 | pub fn as_u64(self) -> u64 { 25 | self.0 26 | } 27 | 28 | pub fn as_i64(self) -> i64 { 29 | self.0 as i64 30 | } 31 | } 32 | 33 | impl Into for Duration { 34 | fn into(self) -> u64 { 35 | self.0 36 | } 37 | } 38 | 39 | impl Div for Duration { 40 | type Output = Duration; 41 | 42 | fn div(self, rhs: Duration) -> Self::Output { 43 | Duration(self.0 / rhs.0) 44 | } 45 | } 46 | 47 | impl Div for Duration { 48 | type Output = Duration; 49 | 50 | fn div(self, rhs: u64) -> Self::Output { 51 | Duration(self.0 / rhs) 52 | } 53 | } 54 | 55 | impl DivAssign for Duration { 56 | fn div_assign(&mut self, rhs: u64) { 57 | *self = Duration(self.0 / rhs); 58 | } 59 | } 60 | 61 | impl Mul for Duration { 62 | type Output = Duration; 63 | 64 | fn mul(self, rhs: u64) -> Self::Output { 65 | Duration(self.0 * rhs) 66 | } 67 | } 68 | 69 | impl MulAssign for Duration { 70 | fn mul_assign(&mut self, rhs: u64) { 71 | *self = Duration(self.0 * rhs); 72 | } 73 | } 74 | 75 | impl fmt::Display for Duration { 76 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 77 | write!(f, "idx range {}", self.0) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/metadata/factory.rs: -------------------------------------------------------------------------------- 1 | use std::boxed::Box; 2 | 3 | use super::{Format, MKVMergeTextFormat, Reader}; 4 | 5 | pub struct Factory {} 6 | 7 | impl Factory { 8 | pub fn get_extensions() -> Vec<(&'static str, Format)> { 9 | let mut result = Vec::<(&'static str, Format)>::new(); 10 | 11 | // Only MKVMergeTextFormat implemented for Read ATM 12 | result.push((MKVMergeTextFormat::get_extension(), Format::MKVMergeText)); 13 | 14 | result 15 | } 16 | 17 | pub fn get_reader(format: Format) -> Box { 18 | match format { 19 | Format::MKVMergeText => MKVMergeTextFormat::new_as_boxed(), 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/metadata/format.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use super::MediaInfo; 4 | 5 | pub trait Reader { 6 | fn read(&self, info: &MediaInfo, source: &mut dyn Read) -> Result, String>; 7 | } 8 | -------------------------------------------------------------------------------- /src/metadata/media_info.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use gst::Tag; 3 | use lazy_static::lazy_static; 4 | 5 | use std::{ 6 | collections::HashMap, 7 | fmt, 8 | path::{Path, PathBuf}, 9 | sync::Arc, 10 | }; 11 | 12 | use super::{Duration, MediaContent}; 13 | 14 | #[derive(Debug)] 15 | pub struct SelectStreamError(Arc); 16 | 17 | impl SelectStreamError { 18 | fn new(id: &Arc) -> Self { 19 | SelectStreamError(Arc::clone(&id)) 20 | } 21 | 22 | pub fn id(&self) -> &Arc { 23 | &self.0 24 | } 25 | } 26 | 27 | impl fmt::Display for SelectStreamError { 28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 | write!(f, "MediaInfo: unknown stream id {}", self.0) 30 | } 31 | } 32 | impl std::error::Error for SelectStreamError {} 33 | 34 | pub fn get_default_chapter_title() -> String { 35 | gettext("untitled") 36 | } 37 | 38 | macro_rules! add_tag_names ( 39 | ($($tag_type:path),+) => { 40 | { 41 | let mut tag_names = Vec::new(); 42 | $(tag_names.push(<$tag_type>::tag_name());)+ 43 | tag_names 44 | } 45 | }; 46 | ); 47 | 48 | lazy_static! { 49 | static ref TAGS_TO_SKIP_FOR_TRACK: Vec<&'static str> = { 50 | add_tag_names!( 51 | gst::tags::Album, 52 | gst::tags::AlbumSortname, 53 | gst::tags::AlbumSortname, 54 | gst::tags::AlbumArtist, 55 | gst::tags::AlbumArtistSortname, 56 | gst::tags::ApplicationName, 57 | gst::tags::ApplicationData, 58 | gst::tags::Artist, 59 | gst::tags::ArtistSortname, 60 | gst::tags::AudioCodec, 61 | gst::tags::Codec, 62 | gst::tags::ContainerFormat, 63 | gst::tags::Duration, 64 | gst::tags::Encoder, 65 | gst::tags::EncoderVersion, 66 | gst::tags::Image, 67 | gst::tags::ImageOrientation, 68 | gst::tags::PreviewImage, 69 | gst::tags::SubtitleCodec, 70 | gst::tags::Title, 71 | gst::tags::TitleSortname, 72 | gst::tags::TrackCount, 73 | gst::tags::TrackNumber, 74 | gst::tags::VideoCodec 75 | ) 76 | }; 77 | } 78 | 79 | #[derive(Debug, Clone)] 80 | pub struct Stream { 81 | pub id: Arc, 82 | pub codec_printable: String, 83 | pub caps: gst::Caps, 84 | pub tags: gst::TagList, 85 | pub type_: gst::StreamType, 86 | } 87 | 88 | impl Stream { 89 | fn new(stream: &gst::Stream) -> Self { 90 | let caps = stream.get_caps().unwrap(); 91 | let tags = stream.get_tags().unwrap_or_else(gst::TagList::new); 92 | let type_ = stream.get_stream_type(); 93 | 94 | let codec_printable = match type_ { 95 | gst::StreamType::AUDIO => tags.get_index::(0), 96 | gst::StreamType::VIDEO => tags.get_index::(0), 97 | gst::StreamType::TEXT => tags.get_index::(0), 98 | _ => panic!("Stream::new can't handle {:?}", type_), 99 | } 100 | .or_else(|| tags.get_index::(0)) 101 | .and_then(|value| value.get()) 102 | .map_or_else( 103 | || { 104 | // codec in caps in the form "streamtype/x-codec" 105 | let codec = caps.get_structure(0).unwrap().get_name(); 106 | let id_parts: Vec<&str> = codec.split('/').collect(); 107 | if id_parts.len() == 2 { 108 | if id_parts[1].starts_with("x-") { 109 | id_parts[1][2..].to_string() 110 | } else { 111 | id_parts[1].to_string() 112 | } 113 | } else { 114 | codec.to_string() 115 | } 116 | }, 117 | ToString::to_string, 118 | ); 119 | 120 | Stream { 121 | id: stream.get_stream_id().unwrap().as_str().into(), 122 | codec_printable, 123 | caps, 124 | tags, 125 | type_, 126 | } 127 | } 128 | } 129 | 130 | #[derive(Debug)] 131 | pub struct StreamCollection { 132 | type_: gst::StreamType, 133 | collection: HashMap, Stream>, 134 | } 135 | 136 | impl StreamCollection { 137 | fn new(type_: gst::StreamType) -> Self { 138 | StreamCollection { 139 | type_, 140 | collection: HashMap::new(), 141 | } 142 | } 143 | 144 | fn add_stream(&mut self, stream: Stream) { 145 | self.collection.insert(Arc::clone(&stream.id), stream); 146 | } 147 | 148 | pub fn get>(&self, id: S) -> Option<&Stream> { 149 | self.collection.get(id.as_ref()) 150 | } 151 | 152 | pub fn contains>(&self, id: S) -> bool { 153 | self.collection.contains_key(id.as_ref()) 154 | } 155 | 156 | pub fn sorted(&self) -> impl Iterator { 157 | SortedStreamCollectionIter::new(self) 158 | } 159 | } 160 | 161 | struct SortedStreamCollectionIter<'sc> { 162 | collection: &'sc StreamCollection, 163 | sorted_iter: std::vec::IntoIter>, 164 | } 165 | 166 | impl<'sc> SortedStreamCollectionIter<'sc> { 167 | fn new(collection: &'sc StreamCollection) -> Self { 168 | let mut sorted_ids: Vec> = collection.collection.keys().map(Arc::clone).collect(); 169 | sorted_ids.sort(); 170 | 171 | SortedStreamCollectionIter { 172 | collection, 173 | sorted_iter: sorted_ids.into_iter(), 174 | } 175 | } 176 | } 177 | 178 | impl<'sc> Iterator for SortedStreamCollectionIter<'sc> { 179 | type Item = &'sc Stream; 180 | 181 | fn next(&mut self) -> Option { 182 | self.sorted_iter 183 | .next() 184 | .and_then(|id| self.collection.get(&id)) 185 | } 186 | } 187 | 188 | #[derive(Debug)] 189 | pub struct Streams { 190 | pub audio: StreamCollection, 191 | pub video: StreamCollection, 192 | pub text: StreamCollection, 193 | 194 | cur_audio_id: Option>, 195 | pub audio_changed: bool, 196 | cur_video_id: Option>, 197 | pub video_changed: bool, 198 | cur_text_id: Option>, 199 | pub text_changed: bool, 200 | } 201 | 202 | impl Default for Streams { 203 | fn default() -> Self { 204 | Streams { 205 | audio: StreamCollection::new(gst::StreamType::AUDIO), 206 | video: StreamCollection::new(gst::StreamType::VIDEO), 207 | text: StreamCollection::new(gst::StreamType::TEXT), 208 | 209 | cur_audio_id: None, 210 | audio_changed: false, 211 | cur_video_id: None, 212 | video_changed: false, 213 | cur_text_id: None, 214 | text_changed: false, 215 | } 216 | } 217 | } 218 | 219 | impl Streams { 220 | pub fn add_stream(&mut self, gst_stream: &gst::Stream) { 221 | let stream = Stream::new(gst_stream); 222 | match stream.type_ { 223 | gst::StreamType::AUDIO => { 224 | self.cur_audio_id.get_or_insert(Arc::clone(&stream.id)); 225 | self.audio.add_stream(stream); 226 | } 227 | gst::StreamType::VIDEO => { 228 | self.cur_video_id.get_or_insert(Arc::clone(&stream.id)); 229 | self.video.add_stream(stream); 230 | } 231 | gst::StreamType::TEXT => { 232 | self.cur_text_id.get_or_insert(Arc::clone(&stream.id)); 233 | self.text.add_stream(stream); 234 | } 235 | other => unimplemented!("{:?}", other), 236 | } 237 | } 238 | 239 | pub fn collection(&self, type_: gst::StreamType) -> &StreamCollection { 240 | match type_ { 241 | gst::StreamType::AUDIO => &self.audio, 242 | gst::StreamType::VIDEO => &self.video, 243 | gst::StreamType::TEXT => &self.text, 244 | other => unimplemented!("{:?}", other), 245 | } 246 | } 247 | 248 | pub fn is_video_selected(&self) -> bool { 249 | self.cur_video_id.is_some() 250 | } 251 | 252 | pub fn selected_audio(&self) -> Option<&Stream> { 253 | self.cur_audio_id 254 | .as_ref() 255 | .and_then(|stream_id| self.audio.get(stream_id)) 256 | } 257 | 258 | pub fn selected_video(&self) -> Option<&Stream> { 259 | self.cur_video_id 260 | .as_ref() 261 | .and_then(|stream_id| self.video.get(stream_id)) 262 | } 263 | 264 | pub fn selected_text(&self) -> Option<&Stream> { 265 | self.cur_text_id 266 | .as_ref() 267 | .and_then(|stream_id| self.text.get(stream_id)) 268 | } 269 | 270 | pub fn select_streams(&mut self, ids: &[Arc]) -> Result<(), SelectStreamError> { 271 | let mut is_audio_selected = false; 272 | let mut is_text_selected = false; 273 | let mut is_video_selected = false; 274 | 275 | for id in ids { 276 | if self.audio.contains(id) { 277 | is_audio_selected = true; 278 | self.audio_changed = self 279 | .selected_audio() 280 | .map_or(true, |prev_stream| *id != prev_stream.id); 281 | self.cur_audio_id = Some(Arc::clone(id)); 282 | } else if self.text.contains(id) { 283 | is_text_selected = true; 284 | self.text_changed = self 285 | .selected_text() 286 | .map_or(true, |prev_stream| *id != prev_stream.id); 287 | self.cur_text_id = Some(Arc::clone(id)); 288 | } else if self.video.contains(id) { 289 | is_video_selected = true; 290 | self.video_changed = self 291 | .selected_video() 292 | .map_or(true, |prev_stream| *id != prev_stream.id); 293 | self.cur_video_id = Some(Arc::clone(id)); 294 | } else { 295 | return Err(SelectStreamError::new(id)); 296 | } 297 | } 298 | 299 | if !is_audio_selected { 300 | self.audio_changed = self.cur_audio_id.take().map_or(false, |_| true); 301 | } 302 | if !is_text_selected { 303 | self.text_changed = self.cur_text_id.take().map_or(false, |_| true); 304 | } 305 | if !is_video_selected { 306 | self.video_changed = self.cur_video_id.take().map_or(false, |_| true); 307 | } 308 | 309 | Ok(()) 310 | } 311 | 312 | pub fn audio_codec(&self) -> Option<&str> { 313 | self.selected_audio() 314 | .map(|stream| stream.codec_printable.as_str()) 315 | } 316 | 317 | pub fn video_codec(&self) -> Option<&str> { 318 | self.selected_video() 319 | .map(|stream| stream.codec_printable.as_str()) 320 | } 321 | 322 | fn tag_list<'a, T: gst::Tag<'a>>(&'a self) -> Option<&gst::TagList> { 323 | self.selected_audio() 324 | .and_then(|selected_audio| { 325 | if selected_audio.tags.get_size::() > 0 { 326 | Some(&selected_audio.tags) 327 | } else { 328 | None 329 | } 330 | }) 331 | .or_else(|| { 332 | self.selected_video().and_then(|selected_video| { 333 | if selected_video.tags.get_size::() > 0 { 334 | Some(&selected_video.tags) 335 | } else { 336 | None 337 | } 338 | }) 339 | }) 340 | } 341 | } 342 | 343 | #[derive(Debug, Default)] 344 | pub struct MediaInfo { 345 | pub name: String, 346 | pub file_name: String, 347 | pub path: PathBuf, 348 | pub content: MediaContent, 349 | pub tags: gst::TagList, 350 | pub toc: Option, 351 | pub chapter_count: Option, 352 | 353 | pub description: String, 354 | pub duration: Duration, 355 | 356 | pub streams: Streams, 357 | } 358 | 359 | impl MediaInfo { 360 | pub fn new(path: &Path) -> Self { 361 | MediaInfo { 362 | name: path.file_stem().unwrap().to_str().unwrap().to_owned(), 363 | file_name: path.file_name().unwrap().to_str().unwrap().to_owned(), 364 | path: path.to_owned(), 365 | ..MediaInfo::default() 366 | } 367 | } 368 | 369 | pub fn add_stream(&mut self, gst_stream: &gst::Stream) { 370 | self.streams.add_stream(gst_stream); 371 | self.content.add_stream_type(gst_stream.get_stream_type()); 372 | } 373 | 374 | pub fn add_tags(&mut self, tags: &gst::TagList) { 375 | self.tags = self.tags.merge(tags, gst::TagMergeMode::Keep); 376 | } 377 | 378 | fn tag_list<'a, T: gst::Tag<'a>>(&'a self) -> Option<&gst::TagList> { 379 | if self.tags.get_size::() > 0 { 380 | Some(&self.tags) 381 | } else { 382 | None 383 | } 384 | } 385 | 386 | fn tag_for_display<'a, Primary, Secondary>( 387 | &'a self, 388 | ) -> Option<>::TagType> 389 | where 390 | Primary: gst::Tag<'a> + 'a, 391 | Secondary: gst::Tag<'a, TagType = >::TagType> + 'a, 392 | { 393 | self.tag_list::() 394 | .or_else(|| self.tag_list::()) 395 | .or_else(|| { 396 | self.streams 397 | .tag_list::() 398 | .or_else(|| self.streams.tag_list::()) 399 | }) 400 | .and_then(|tag_list| { 401 | tag_list 402 | .get_index::(0) 403 | .or_else(|| tag_list.get_index::(0)) 404 | .and_then(|value| value.get()) 405 | }) 406 | } 407 | 408 | pub fn media_artist(&self) -> Option<&str> { 409 | self.tag_for_display::() 410 | } 411 | 412 | pub fn media_title(&self) -> Option<&str> { 413 | self.tag_for_display::() 414 | } 415 | 416 | pub fn media_image(&self) -> Option { 417 | self.tag_for_display::() 418 | } 419 | 420 | pub fn container(&self) -> Option<&str> { 421 | // in case of an mp3 audio file, container comes as `ID3 label` 422 | // => bypass it 423 | if let Some(audio_codec) = self.streams.audio_codec() { 424 | if self.streams.video_codec().is_none() 425 | && audio_codec.to_lowercase().find("mp3").is_some() 426 | { 427 | return None; 428 | } 429 | } 430 | 431 | self.tags 432 | .get_index::(0) 433 | .and_then(|value| value.get()) 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/metadata/mkvmerge_text_format.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | 3 | use log::error; 4 | 5 | use nom::{ 6 | bytes::complete::tag, 7 | character::complete::{line_ending, not_line_ending}, 8 | combinator::{opt, verify}, 9 | error::ErrorKind, 10 | sequence::{pair, preceded, separated_pair, terminated}, 11 | IResult, 12 | }; 13 | 14 | use std::io::Read; 15 | 16 | use super::{parse_timestamp, parse_to, MediaInfo, Reader, Timestamp4Humans}; 17 | 18 | static EXTENSION: &str = "txt"; 19 | 20 | static CHAPTER_TAG: &str = "CHAPTER"; 21 | static NAME_TAG: &str = "NAME"; 22 | 23 | pub struct MKVMergeTextFormat {} 24 | 25 | impl MKVMergeTextFormat { 26 | pub fn get_extension() -> &'static str { 27 | EXTENSION 28 | } 29 | 30 | pub fn new_as_boxed() -> Box { 31 | Box::new(MKVMergeTextFormat {}) 32 | } 33 | } 34 | 35 | fn new_chapter(nb: usize, start_ts: Timestamp4Humans, title: &str) -> gst::TocEntry { 36 | let mut chapter = gst::TocEntry::new(gst::TocEntryType::Chapter, &format!("{:02}", nb)); 37 | let start = start_ts.nano_total() as i64; 38 | chapter 39 | .get_mut() 40 | .unwrap() 41 | .set_start_stop_times(start, start); 42 | 43 | let mut tag_list = gst::TagList::new(); 44 | tag_list 45 | .get_mut() 46 | .unwrap() 47 | .add::(&title, gst::TagMergeMode::Replace); 48 | chapter.get_mut().unwrap().set_tags(tag_list); 49 | chapter 50 | } 51 | 52 | fn parse_chapter(i: &str) -> IResult<&str, gst::TocEntry> { 53 | let parse_first_line = terminated( 54 | preceded( 55 | tag(CHAPTER_TAG), 56 | separated_pair(parse_to::, tag("="), parse_timestamp), 57 | ), 58 | line_ending, 59 | ); 60 | 61 | let (i, (nb, start_ts)) = parse_first_line(i)?; 62 | 63 | let parse_second_line = terminated( 64 | preceded( 65 | tag(CHAPTER_TAG), 66 | separated_pair( 67 | verify(parse_to::, |nb2| nb == *nb2), 68 | pair(tag(NAME_TAG), tag("=")), 69 | not_line_ending, 70 | ), 71 | ), 72 | opt(line_ending), 73 | ); 74 | 75 | parse_second_line(i).map(|(i, (_, title))| (i, new_chapter(nb, start_ts, title))) 76 | } 77 | 78 | #[test] 79 | fn parse_chapter_test() { 80 | use nom::{error::ErrorKind, InputLength}; 81 | gst::init().unwrap(); 82 | 83 | let res = parse_chapter("CHAPTER01=00:00:01.000\nCHAPTER01NAME=test\n"); 84 | let (i, toc_entry) = res.unwrap(); 85 | assert_eq!(0, i.input_len()); 86 | assert_eq!(1_000_000_000, toc_entry.get_start_stop_times().unwrap().0); 87 | assert_eq!( 88 | Some("test".to_string()), 89 | toc_entry.get_tags().and_then(|tags| tags 90 | .get::() 91 | .and_then(|tag| tag.get().map(|value| value.to_string()))), 92 | ); 93 | 94 | let res = parse_chapter("CHAPTER01=00:00:01.000\r\nCHAPTER01NAME=test\r\n"); 95 | let (i, toc_entry) = res.unwrap(); 96 | assert_eq!(0, i.input_len()); 97 | assert_eq!(1_000_000_000, toc_entry.get_start_stop_times().unwrap().0); 98 | assert_eq!( 99 | Some("test".to_owned()), 100 | toc_entry.get_tags().and_then(|tags| tags 101 | .get::() 102 | .and_then(|tag| tag.get().map(|value| value.to_string()))), 103 | ); 104 | 105 | let res = parse_chapter("CHAPTER0x=00:00:01.000"); 106 | let err = res.unwrap_err(); 107 | if let nom::Err::Error((i, error_kind)) = err { 108 | assert_eq!("x=00:00:01.000", i); 109 | assert_eq!(ErrorKind::Tag, error_kind); 110 | } else { 111 | panic!("unexpected error type returned"); 112 | } 113 | 114 | let res = parse_chapter("CHAPTER01=00:00:01.000\nCHAPTER02NAME=test\n"); 115 | let err = res.unwrap_err(); 116 | if let nom::Err::Error((i, error_kind)) = err { 117 | assert_eq!("02NAME=test\n", i); 118 | assert_eq!(ErrorKind::Verify, error_kind); 119 | } else { 120 | panic!("unexpected error type returned"); 121 | } 122 | } 123 | 124 | impl Reader for MKVMergeTextFormat { 125 | fn read(&self, info: &MediaInfo, source: &mut dyn Read) -> Result, String> { 126 | let error_msg = gettext("unexpected error reading mkvmerge text file."); 127 | let mut content = String::new(); 128 | source.read_to_string(&mut content).map_err(|_| { 129 | error!("{}", error_msg); 130 | error_msg.clone() 131 | })?; 132 | 133 | if !content.is_empty() { 134 | let mut toc_edition = gst::TocEntry::new(gst::TocEntryType::Edition, ""); 135 | let mut last_chapter: Option = None; 136 | let mut input = content.as_str(); 137 | 138 | while !input.is_empty() { 139 | let cur_chapter = match parse_chapter(input) { 140 | Ok((i, cur_chapter)) => { 141 | if i.len() == input.len() { 142 | // No progress 143 | if !i.is_empty() { 144 | let msg = gettext("unexpected sequence starting with: {}") 145 | .replacen("{}", &i[..i.len().min(10)], 1); 146 | error!("{}", msg); 147 | return Err(msg); 148 | } 149 | break; 150 | } 151 | input = i; 152 | cur_chapter 153 | } 154 | Err(err) => { 155 | let msg = if let nom::Err::Error((i, error_kind)) = err { 156 | match error_kind { 157 | ErrorKind::ParseTo => gettext("expecting a number, found: {}") 158 | .replacen("{}", &i[..i.len().min(2)], 1), 159 | ErrorKind::Verify => gettext("chapter numbers don't match for: {}") 160 | .replacen("{}", &i[..i.len().min(2)], 1), 161 | _ => gettext("unexpected sequence starting with: {}").replacen( 162 | "{}", 163 | &i[..i.len().min(10)], 164 | 1, 165 | ), 166 | } 167 | } else { 168 | error!("unknown error {:?}", err); 169 | error_msg 170 | }; 171 | error!("{}", msg); 172 | return Err(msg); 173 | } 174 | }; 175 | 176 | if let Some(mut prev_chapter) = last_chapter.take() { 177 | // Update previous chapter's end 178 | let prev_start = prev_chapter.get_start_stop_times().unwrap().0; 179 | let cur_start = cur_chapter.get_start_stop_times().unwrap().0; 180 | prev_chapter 181 | .get_mut() 182 | .unwrap() 183 | .set_start_stop_times(prev_start, cur_start); 184 | // Add previous chapter to the Edition entry 185 | toc_edition 186 | .get_mut() 187 | .unwrap() 188 | .append_sub_entry(prev_chapter); 189 | } 190 | 191 | // Queue current chapter (will be added when next chapter start is known 192 | // or with the media's duration when the parsing is done) 193 | last_chapter = Some(cur_chapter); 194 | } 195 | 196 | // Update last_chapter 197 | last_chapter.take().map_or_else( 198 | || { 199 | error!("{}", gettext("couldn't update last start position")); 200 | Err(error_msg) 201 | }, 202 | |mut last_chapter| { 203 | let last_start = last_chapter.get_start_stop_times().unwrap().0; 204 | last_chapter 205 | .get_mut() 206 | .unwrap() 207 | .set_start_stop_times(last_start, info.duration.as_i64()); 208 | toc_edition 209 | .get_mut() 210 | .unwrap() 211 | .append_sub_entry(last_chapter); 212 | 213 | let mut toc = gst::Toc::new(gst::TocScope::Global); 214 | toc.get_mut().unwrap().append_entry(toc_edition); 215 | Ok(Some(toc)) 216 | }, 217 | ) 218 | } else { 219 | // file is empty 220 | Ok(None) 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/metadata/mod.rs: -------------------------------------------------------------------------------- 1 | mod duration; 2 | pub use duration::Duration; 3 | 4 | pub mod factory; 5 | pub use self::factory::Factory; 6 | 7 | mod format; 8 | pub use self::format::Reader; 9 | 10 | pub mod media_info; 11 | pub use self::media_info::{get_default_chapter_title, MediaInfo, Stream, Streams}; 12 | 13 | mod mkvmerge_text_format; 14 | pub use self::mkvmerge_text_format::MKVMergeTextFormat; 15 | 16 | mod timestamp_4_humans; 17 | pub use self::timestamp_4_humans::{parse_timestamp, Timestamp4Humans}; 18 | 19 | mod toc_visitor; 20 | pub use self::toc_visitor::{TocVisit, TocVisitor}; 21 | 22 | #[derive(Clone, Copy, Debug, PartialEq)] 23 | pub enum Format { 24 | MKVMergeText, 25 | } 26 | 27 | #[derive(Clone, Copy, Debug, PartialEq)] 28 | pub enum MediaContent { 29 | Audio, 30 | AudioVideo, 31 | AudioText, 32 | AudioVideoText, 33 | Text, 34 | Video, 35 | VideoText, 36 | Undefined, 37 | } 38 | 39 | impl MediaContent { 40 | pub fn add_stream_type(&mut self, type_: gst::StreamType) { 41 | match type_ { 42 | gst::StreamType::AUDIO => match self { 43 | MediaContent::Text => *self = MediaContent::AudioText, 44 | MediaContent::Video => *self = MediaContent::AudioVideo, 45 | MediaContent::VideoText => *self = MediaContent::AudioVideoText, 46 | MediaContent::Undefined => *self = MediaContent::Audio, 47 | _ => (), 48 | }, 49 | gst::StreamType::VIDEO => match self { 50 | MediaContent::Audio => *self = MediaContent::AudioVideo, 51 | MediaContent::Text => *self = MediaContent::VideoText, 52 | MediaContent::AudioText => *self = MediaContent::AudioVideoText, 53 | MediaContent::Undefined => *self = MediaContent::Video, 54 | _ => (), 55 | }, 56 | gst::StreamType::TEXT => match self { 57 | MediaContent::Audio => *self = MediaContent::AudioText, 58 | MediaContent::Video => *self = MediaContent::VideoText, 59 | MediaContent::AudioVideo => *self = MediaContent::AudioVideoText, 60 | MediaContent::Undefined => *self = MediaContent::Text, 61 | _ => (), 62 | }, 63 | _ => panic!("MediaContent::add_stream_type can't handle {:?}", type_), 64 | }; 65 | } 66 | } 67 | 68 | impl Default for MediaContent { 69 | fn default() -> Self { 70 | MediaContent::Undefined 71 | } 72 | } 73 | 74 | use nom::{character::complete::digit1, error::ErrorKind, Err, IResult}; 75 | 76 | fn parse_to(i: &str) -> IResult<&str, T> { 77 | let (i, res) = digit1(i)?; 78 | 79 | res.parse::() 80 | .map(move |value| (i, value)) 81 | .map_err(move |_| Err::Error((i, ErrorKind::ParseTo))) 82 | } 83 | -------------------------------------------------------------------------------- /src/metadata/timestamp_4_humans.rs: -------------------------------------------------------------------------------- 1 | use nom::{ 2 | branch::alt, 3 | bytes::complete::tag, 4 | combinator::opt, 5 | error::ErrorKind, 6 | sequence::{preceded, separated_pair, tuple}, 7 | Err, IResult, 8 | }; 9 | 10 | use std::{convert::TryFrom, fmt, string::ToString}; 11 | 12 | use super::{parse_to, Duration}; 13 | 14 | pub fn parse_timestamp(i: &str) -> IResult<&str, Timestamp4Humans> { 15 | let parse_timestamp_ = tuple(( 16 | separated_pair(parse_to::, tag(":"), parse_to::), 17 | opt(tuple(( 18 | // the next tag determines whether the 1st number is h or mn 19 | alt((tag(":"), tag("."))), 20 | parse_to::, 21 | opt(preceded(tag("."), parse_to::)), 22 | ))), 23 | )); 24 | 25 | let (i, res) = parse_timestamp_(i)?; 26 | 27 | let ts = match res { 28 | ((h, m), Some((":", s, ms))) => { 29 | let s = u8::try_from(s).map_err(|_| Err::Error((i, ErrorKind::ParseTo)))?; 30 | 31 | Timestamp4Humans { 32 | h, 33 | m, 34 | s, 35 | ms: ms.unwrap_or(0), 36 | ..Timestamp4Humans::default() 37 | } 38 | } 39 | ((m, s), Some((".", ms, us))) => Timestamp4Humans { 40 | h: 0, 41 | m, 42 | s, 43 | ms, 44 | us: us.unwrap_or(0), 45 | ..Timestamp4Humans::default() 46 | }, 47 | ((_, _), Some((_, _, _))) => unreachable!("unexpected separator returned by parser"), 48 | ((h, m), None) => Timestamp4Humans { 49 | h, 50 | m, 51 | ..Timestamp4Humans::default() 52 | }, 53 | }; 54 | 55 | Ok((i, ts)) 56 | } 57 | 58 | #[test] 59 | fn parse_string() { 60 | let ts_res = parse_timestamp("11:42:20.010"); 61 | assert!(ts_res.is_ok()); 62 | let ts = ts_res.unwrap().1; 63 | assert_eq!(ts.h, 11); 64 | assert_eq!(ts.m, 42); 65 | assert_eq!(ts.s, 20); 66 | assert_eq!(ts.ms, 10); 67 | assert_eq!(ts.us, 0); 68 | assert_eq!(ts.nano, 0); 69 | assert_eq!( 70 | ts.nano_total(), 71 | ((((11 * 60 + 42) * 60 + 20) * 1_000) + 10) * 1_000 * 1_000 72 | ); 73 | 74 | let ts_res = parse_timestamp("42:20.010"); 75 | assert!(ts_res.is_ok()); 76 | let ts = ts_res.unwrap().1; 77 | assert_eq!(ts.h, 0); 78 | assert_eq!(ts.m, 42); 79 | assert_eq!(ts.s, 20); 80 | assert_eq!(ts.ms, 10); 81 | assert_eq!(ts.us, 0); 82 | assert_eq!(ts.nano, 0); 83 | assert_eq!( 84 | ts.nano_total(), 85 | (((42 * 60 + 20) * 1_000) + 10) * 1_000 * 1_000 86 | ); 87 | 88 | let ts_res = parse_timestamp("42:20.010.015"); 89 | assert!(ts_res.is_ok()); 90 | let ts = ts_res.unwrap().1; 91 | assert_eq!(ts.h, 0); 92 | assert_eq!(ts.m, 42); 93 | assert_eq!(ts.s, 20); 94 | assert_eq!(ts.ms, 10); 95 | assert_eq!(ts.us, 15); 96 | assert_eq!(ts.nano, 0); 97 | assert_eq!( 98 | ts.nano_total(), 99 | ((((42 * 60 + 20) * 1_000) + 10) * 1_000 + 15) * 1_000 100 | ); 101 | 102 | assert!(parse_timestamp("abc:15").is_err()); 103 | assert!(parse_timestamp("42:aa.015").is_err()); 104 | 105 | let ts_res = parse_timestamp("42:20a"); 106 | assert!(ts_res.is_ok()); 107 | let (i, _) = ts_res.unwrap(); 108 | assert_eq!("a", i); 109 | } 110 | 111 | #[derive(Default)] 112 | pub struct Timestamp4Humans { 113 | pub nano: u16, 114 | pub us: u16, 115 | pub ms: u16, 116 | pub s: u8, 117 | pub m: u8, 118 | pub h: u8, 119 | } 120 | 121 | // New type to force display of hours 122 | pub struct Timestamp4HumansWithHours(Timestamp4Humans); 123 | // New type to force display of micro seconds 124 | pub struct Timestamp4HumansWithMicro(Timestamp4Humans); 125 | 126 | impl Timestamp4Humans { 127 | pub fn from_nano(nano_total: u64) -> Self { 128 | let us_total = nano_total / 1_000; 129 | let ms_total = us_total / 1_000; 130 | let s_total = ms_total / 1_000; 131 | let m_total = s_total / 60; 132 | 133 | Timestamp4Humans { 134 | nano: (nano_total % 1_000) as u16, 135 | us: (us_total % 1_000) as u16, 136 | ms: (ms_total % 1_000) as u16, 137 | s: (s_total % 60) as u8, 138 | m: (m_total % 60) as u8, 139 | h: (m_total / 60) as u8, 140 | } 141 | } 142 | 143 | pub fn nano_total(&self) -> u64 { 144 | ((((u64::from(self.h) * 60 + u64::from(self.m)) * 60 + u64::from(self.s)) * 1_000 145 | + u64::from(self.ms)) 146 | * 1_000 147 | + u64::from(self.us)) 148 | * 1_000 149 | + u64::from(self.nano) 150 | } 151 | 152 | pub fn from_duration(duration: Duration) -> Self { 153 | Self::from_nano(duration.into()) 154 | } 155 | } 156 | 157 | impl ToString for Timestamp4Humans { 158 | fn to_string(&self) -> String { 159 | if self.h == 0 { 160 | format!("{:02}:{:02}.{:03}", self.m, self.s, self.ms) 161 | } else { 162 | format!("{:02}:{:02}:{:02}.{:03}", self.h, self.m, self.s, self.ms,) 163 | } 164 | } 165 | } 166 | 167 | impl fmt::Debug for Timestamp4Humans { 168 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 169 | f.debug_tuple("Timestamp4Humans") 170 | .field(&self.to_string()) 171 | .finish() 172 | } 173 | } 174 | 175 | impl ToString for Timestamp4HumansWithMicro { 176 | fn to_string(&self) -> String { 177 | Timestamp4Humans::to_string(&self.0) + &format!(".{:03}", self.0.us) 178 | } 179 | } 180 | 181 | impl ToString for Timestamp4HumansWithHours { 182 | fn to_string(&self) -> String { 183 | let res = Timestamp4Humans::to_string(&self.0); 184 | if self.0.h == 0 { 185 | format!("{:02}:{}", self.0.h, &res) 186 | } else { 187 | res 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/metadata/toc_visitor.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum TocVisit { 3 | EnteringChildren, 4 | LeavingChildren, 5 | Node(gst::TocEntry), 6 | } 7 | 8 | impl PartialEq for TocVisit { 9 | fn eq(&self, other: &TocVisit) -> bool { 10 | match *self { 11 | TocVisit::EnteringChildren => matches!(*other, TocVisit::EnteringChildren), 12 | TocVisit::LeavingChildren => matches!(*other, TocVisit::LeavingChildren), 13 | TocVisit::Node(ref entry) => match *other { 14 | TocVisit::Node(ref other_entry) => (entry.get_uid() == other_entry.get_uid()), 15 | _ => false, 16 | }, 17 | } 18 | } 19 | } 20 | 21 | struct TocEntryIter { 22 | entries: Vec, 23 | index: usize, 24 | } 25 | 26 | impl TocEntryIter { 27 | fn from(entries: Vec) -> Self { 28 | Self { entries, index: 0 } 29 | } 30 | 31 | fn next(&mut self) -> Option<(gst::TocEntry, usize)> { 32 | if self.index >= self.entries.len() { 33 | return None; 34 | } 35 | 36 | let result = Some((self.entries[self.index].clone(), self.index)); 37 | self.index += 1; 38 | result 39 | } 40 | } 41 | 42 | pub struct TocVisitor { 43 | stack: Vec, 44 | next_to_push: Option, 45 | } 46 | 47 | impl TocVisitor { 48 | pub fn new(toc: &gst::Toc) -> TocVisitor { 49 | let entries = toc.get_entries(); 50 | let next_to_push = if !entries.is_empty() { 51 | Some(TocEntryIter::from(entries)) 52 | } else { 53 | None 54 | }; 55 | 56 | TocVisitor { 57 | stack: Vec::new(), 58 | next_to_push, 59 | } 60 | } 61 | 62 | pub fn enter_chapters(&mut self) -> bool { 63 | // Skip edition entry and enter chapters 64 | assert_eq!(Some(TocVisit::EnteringChildren), self.next()); 65 | let found_edition = match self.next() { 66 | Some(TocVisit::Node(entry)) => gst::TocEntryType::Edition == entry.get_entry_type(), 67 | _ => false, 68 | }; 69 | 70 | if found_edition { 71 | self.next() 72 | .map_or(false, |visit| TocVisit::EnteringChildren == visit) 73 | } else { 74 | false 75 | } 76 | } 77 | 78 | fn next(&mut self) -> Option { 79 | match self.next_to_push.take() { 80 | None => { 81 | if self.stack.is_empty() { 82 | // Nothing left to be done 83 | None 84 | } else { 85 | let mut iter = self.stack.pop().unwrap(); 86 | match iter.next() { 87 | Some((entry, _index)) => { 88 | self.stack.push(iter); 89 | let subentries = entry.get_sub_entries(); 90 | if !subentries.is_empty() { 91 | self.next_to_push = Some(TocEntryIter::from(subentries)); 92 | } 93 | Some(TocVisit::Node(entry)) 94 | } 95 | None => Some(TocVisit::LeavingChildren), 96 | } 97 | } 98 | } 99 | Some(next_to_push) => { 100 | self.stack.push(next_to_push); 101 | Some(TocVisit::EnteringChildren) 102 | } 103 | } 104 | } 105 | 106 | // Flattens the tree structure and get chapters in order 107 | pub fn next_chapter(&mut self) -> Option { 108 | loop { 109 | match self.next() { 110 | Some(toc_visit) => { 111 | if let TocVisit::Node(entry) = toc_visit { 112 | if let gst::TocEntryType::Chapter = entry.get_entry_type() { 113 | return Some(entry); 114 | } 115 | } 116 | } 117 | None => return None, 118 | } 119 | } 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | extern crate gstreamer as gst; 126 | use gstreamer::{Toc, TocEntry, TocEntryType, TocScope}; 127 | 128 | use super::*; 129 | 130 | #[test] 131 | fn subchapters() { 132 | gst::init().unwrap(); 133 | 134 | let mut toc = Toc::new(TocScope::Global); 135 | { 136 | let mut edition = TocEntry::new(TocEntryType::Edition, "edition"); 137 | 138 | let mut chapter_1 = TocEntry::new(TocEntryType::Chapter, "1"); 139 | let chapter_1_1 = TocEntry::new(TocEntryType::Chapter, "1.1"); 140 | let chapter_1_2 = TocEntry::new(TocEntryType::Chapter, "1.2"); 141 | chapter_1.get_mut().unwrap().append_sub_entry(chapter_1_1); 142 | chapter_1.get_mut().unwrap().append_sub_entry(chapter_1_2); 143 | edition.get_mut().unwrap().append_sub_entry(chapter_1); 144 | 145 | let mut chapter_2 = TocEntry::new(TocEntryType::Chapter, "2"); 146 | let chapter_2_1 = TocEntry::new(TocEntryType::Chapter, "2.1"); 147 | let chapter_2_2 = TocEntry::new(TocEntryType::Chapter, "2.2"); 148 | chapter_2.get_mut().unwrap().append_sub_entry(chapter_2_1); 149 | chapter_2.get_mut().unwrap().append_sub_entry(chapter_2_2); 150 | edition.get_mut().unwrap().append_sub_entry(chapter_2); 151 | 152 | toc.get_mut().unwrap().append_entry(edition); 153 | } 154 | 155 | let mut toc_visitor = TocVisitor::new(&toc); 156 | assert_eq!(Some(TocVisit::EnteringChildren), toc_visitor.next()); 157 | assert_eq!( 158 | Some(TocVisit::Node(TocEntry::new( 159 | TocEntryType::Edition, 160 | "edition" 161 | ))), 162 | toc_visitor.next(), 163 | ); 164 | 165 | assert_eq!(Some(TocVisit::EnteringChildren), toc_visitor.next()); 166 | assert_eq!( 167 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "1"))), 168 | toc_visitor.next(), 169 | ); 170 | assert_eq!(Some(TocVisit::EnteringChildren), toc_visitor.next()); 171 | assert_eq!( 172 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "1.1"))), 173 | toc_visitor.next(), 174 | ); 175 | assert_eq!( 176 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "1.2"))), 177 | toc_visitor.next(), 178 | ); 179 | assert_eq!(Some(TocVisit::LeavingChildren), toc_visitor.next()); 180 | 181 | assert_eq!( 182 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "2"))), 183 | toc_visitor.next(), 184 | ); 185 | assert_eq!(Some(TocVisit::EnteringChildren), toc_visitor.next()); 186 | assert_eq!( 187 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "2.1"))), 188 | toc_visitor.next(), 189 | ); 190 | assert_eq!( 191 | Some(TocVisit::Node(TocEntry::new(TocEntryType::Chapter, "2.2"))), 192 | toc_visitor.next(), 193 | ); 194 | assert_eq!(Some(TocVisit::LeavingChildren), toc_visitor.next()); // sub chapters 195 | 196 | assert_eq!(Some(TocVisit::LeavingChildren), toc_visitor.next()); // chapters 197 | 198 | assert_eq!(Some(TocVisit::LeavingChildren), toc_visitor.next()); // edition 199 | assert!(toc_visitor.next().is_none()); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/metadata/ui_event.rs: -------------------------------------------------------------------------------- 1 | use glib; 2 | use gstreamer as gst; 3 | use gtk; 4 | use std::{borrow::Cow, path::PathBuf, rc::Rc}; 5 | 6 | #[derive(Clone)] 7 | pub enum UIEvent { 8 | AskQuestion { 9 | question: Cow<'static, str>, 10 | response_cb: Rc, 11 | }, 12 | CancelSelectMedia, 13 | OpenMedia(PathBuf), 14 | PlayRange { 15 | start: u64, 16 | end: u64, 17 | pos_to_restore: u64, 18 | }, 19 | ResetCursor, 20 | Seek { 21 | position: u64, 22 | flags: gst::SeekFlags, 23 | }, 24 | ShowAll, 25 | SetCursorWaiting, 26 | SetCursorDoubleArrow, 27 | ShowError(Cow<'static, str>), 28 | ShowInfo(Cow<'static, str>), 29 | } 30 | 31 | #[derive(Clone)] 32 | pub struct UIEventSender(glib::Sender); 33 | 34 | #[allow(unused_must_use)] 35 | impl UIEventSender { 36 | pub fn ask_question(&self, question: Q, response_cb: Rc) 37 | where 38 | Q: Into>, 39 | { 40 | self.0.send(UIEvent::AskQuestion { 41 | question: question.into(), 42 | response_cb, 43 | }); 44 | } 45 | 46 | pub fn cancel_select_media(&self) { 47 | self.0.send(UIEvent::CancelSelectMedia); 48 | } 49 | 50 | pub fn open_media(&self, path: PathBuf) { 51 | // Trigger the message asynchronously otherwise the waiting cursor might not show up 52 | let mut path = Some(path); 53 | let sender = self.0.clone(); 54 | gtk::idle_add(move || { 55 | if let Some(path) = path.take() { 56 | sender.send(UIEvent::OpenMedia(path)); 57 | } 58 | glib::Continue(false) 59 | }); 60 | } 61 | 62 | pub fn play_range(&self, start: u64, end: u64, pos_to_restore: u64) { 63 | self.0.send(UIEvent::PlayRange { 64 | start, 65 | end, 66 | pos_to_restore, 67 | }); 68 | } 69 | 70 | pub fn reset_cursor(&self) { 71 | self.0.send(UIEvent::ResetCursor); 72 | } 73 | 74 | pub fn show_all(&self) { 75 | self.0.send(UIEvent::ShowAll); 76 | } 77 | 78 | pub fn seek(&self, position: u64, flags: gst::SeekFlags) { 79 | self.0.send(UIEvent::Seek { position, flags }); 80 | } 81 | 82 | pub fn set_cursor_double_arrow(&self) { 83 | self.0.send(UIEvent::SetCursorDoubleArrow); 84 | } 85 | 86 | pub fn set_cursor_waiting(&self) { 87 | self.0.send(UIEvent::SetCursorWaiting); 88 | } 89 | 90 | pub fn show_error(&self, msg: Msg) 91 | where 92 | Msg: Into>, 93 | { 94 | self.0.send(UIEvent::ShowError(msg.into())); 95 | } 96 | 97 | pub fn show_info(&self, msg: Msg) 98 | where 99 | Msg: Into>, 100 | { 101 | self.0.send(UIEvent::ShowInfo(msg.into())); 102 | } 103 | } 104 | 105 | impl From> for UIEventSender { 106 | fn from(glib_ui_event: glib::Sender) -> Self { 107 | UIEventSender(glib_ui_event) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/ui/chapter_tree_manager.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | 3 | use gettextrs::gettext; 4 | 5 | use gtk::prelude::*; 6 | 7 | use std::{borrow::Cow, fmt}; 8 | 9 | use crate::{ 10 | media::Timestamp, 11 | metadata::{get_default_chapter_title, TocVisitor}, 12 | }; 13 | 14 | const START_COL: u32 = 0; 15 | const END_COL: u32 = 1; 16 | const TITLE_COL: u32 = 2; 17 | const START_STR_COL: u32 = 3; 18 | const END_STR_COL: u32 = 4; 19 | 20 | #[derive(Clone, Copy, Debug)] 21 | pub struct ChapterTimestamps { 22 | pub start: Timestamp, 23 | pub end: Timestamp, 24 | } 25 | 26 | impl ChapterTimestamps { 27 | pub fn new_from_u64(start: u64, end: u64) -> Self { 28 | ChapterTimestamps { 29 | start: Timestamp::new(start), 30 | end: Timestamp::new(end), 31 | } 32 | } 33 | } 34 | 35 | impl fmt::Display for ChapterTimestamps { 36 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 37 | write!( 38 | f, 39 | "start {}, end {}", 40 | self.start.for_humans().to_string(), 41 | self.end.for_humans().to_string(), 42 | ) 43 | } 44 | } 45 | 46 | pub struct ChapterIterStart { 47 | pub iter: gtk::TreeIter, 48 | pub start: Timestamp, 49 | } 50 | 51 | pub enum PositionStatus { 52 | ChapterChanged { 53 | prev_chapter: Option, 54 | }, 55 | ChapterNotChanged, 56 | } 57 | 58 | impl From> for PositionStatus { 59 | fn from(prev_chapter: Option) -> Self { 60 | PositionStatus::ChapterChanged { prev_chapter } 61 | } 62 | } 63 | 64 | bitflags! { 65 | struct ColumnOptions: u32 { 66 | const NONE = 0b0000_0000; 67 | const CAN_EXPAND = 0b0000_0001; 68 | } 69 | } 70 | 71 | #[derive(Clone)] 72 | pub struct ChapterEntry<'entry> { 73 | store: &'entry gtk::TreeStore, 74 | iter: Cow<'entry, gtk::TreeIter>, 75 | } 76 | 77 | impl<'entry> ChapterEntry<'entry> { 78 | fn new(store: &'entry gtk::TreeStore, iter: &'entry gtk::TreeIter) -> ChapterEntry<'entry> { 79 | ChapterEntry { 80 | store, 81 | iter: Cow::Borrowed(iter), 82 | } 83 | } 84 | 85 | fn new_owned(store: &'entry gtk::TreeStore, iter: gtk::TreeIter) -> ChapterEntry<'entry> { 86 | ChapterEntry { 87 | store, 88 | iter: Cow::Owned(iter), 89 | } 90 | } 91 | 92 | pub fn iter(&self) -> >k::TreeIter { 93 | self.iter.as_ref() 94 | } 95 | 96 | pub fn start(&self) -> Timestamp { 97 | self.store 98 | .get_value(&self.iter, START_COL as i32) 99 | .get_some::() 100 | .unwrap() 101 | .into() 102 | } 103 | 104 | pub fn end(&self) -> Timestamp { 105 | self.store 106 | .get_value(&self.iter, END_COL as i32) 107 | .get_some::() 108 | .unwrap() 109 | .into() 110 | } 111 | 112 | pub fn timestamps(&self) -> ChapterTimestamps { 113 | ChapterTimestamps { 114 | start: self.start(), 115 | end: self.end(), 116 | } 117 | } 118 | } 119 | 120 | struct ChapterTree { 121 | store: gtk::TreeStore, 122 | iter: Option, 123 | selected: Option, 124 | } 125 | 126 | impl ChapterTree { 127 | fn new(store: gtk::TreeStore) -> Self { 128 | ChapterTree { 129 | store, 130 | iter: None, 131 | selected: None, 132 | } 133 | } 134 | 135 | fn store(&self) -> >k::TreeStore { 136 | &self.store 137 | } 138 | 139 | fn clear(&mut self) { 140 | self.selected = None; 141 | self.iter = None; 142 | self.store.clear(); 143 | } 144 | 145 | fn unselect(&mut self) { 146 | self.selected = None; 147 | } 148 | 149 | fn rewind(&mut self) { 150 | self.iter = self.store.get_iter_first(); 151 | self.selected = match &self.iter_chapter() { 152 | Some(first_chapter) => { 153 | if first_chapter.start() == Timestamp::default() { 154 | self.iter.clone() 155 | } else { 156 | None 157 | } 158 | } 159 | None => None, 160 | }; 161 | } 162 | 163 | fn chapter_from_path(&self, tree_path: >k::TreePath) -> Option> { 164 | self.store 165 | .get_iter(tree_path) 166 | .map(|iter| ChapterEntry::new_owned(&self.store, iter)) 167 | } 168 | 169 | fn selected_chapter(&self) -> Option> { 170 | self.selected 171 | .as_ref() 172 | .map(|selected| ChapterEntry::new(&self.store, selected)) 173 | } 174 | 175 | fn selected_timestamps(&self) -> Option { 176 | self.selected_chapter().map(|chapter| chapter.timestamps()) 177 | } 178 | 179 | fn selected_path(&self) -> Option { 180 | self.selected 181 | .as_ref() 182 | .and_then(|sel_iter| self.store.get_path(sel_iter)) 183 | } 184 | 185 | fn iter_chapter(&self) -> Option> { 186 | self.iter 187 | .as_ref() 188 | .map(|iter| ChapterEntry::new(&self.store, iter)) 189 | } 190 | 191 | fn iter_timestamps(&self) -> Option { 192 | self.iter_chapter().map(|chapter| chapter.timestamps()) 193 | } 194 | 195 | fn new_iter(&self) -> Iter<'_> { 196 | Iter::new(&self.store) 197 | } 198 | 199 | fn next(&mut self) -> Option> { 200 | match self.iter.take() { 201 | Some(iter) => { 202 | if self.store.iter_next(&iter) { 203 | self.iter = Some(iter); 204 | let store = &self.store; 205 | self.iter 206 | .as_ref() 207 | .map(|iter| ChapterEntry::new(store, iter)) 208 | } else { 209 | None 210 | } 211 | } 212 | None => None, 213 | } 214 | } 215 | 216 | fn pick_next(&self) -> Option> { 217 | match self.selected.as_ref() { 218 | Some(selected) => { 219 | let iter = selected.clone(); 220 | if self.store.iter_next(&iter) { 221 | Some(ChapterEntry::new_owned(&self.store, iter)) 222 | } else { 223 | // FIXME: with hierarchical tocs, this might be a case where 224 | // we should check whether the parent node contains something 225 | None 226 | } 227 | } 228 | None => self 229 | .store 230 | .get_iter_first() 231 | .map(|first_iter| ChapterEntry::new_owned(&self.store, first_iter)), 232 | } 233 | } 234 | 235 | fn previous(&mut self) -> Option> { 236 | match self.iter.take() { 237 | Some(iter) => { 238 | if self.store.iter_previous(&iter) { 239 | self.iter = Some(iter); 240 | let store = &self.store; 241 | self.iter 242 | .as_ref() 243 | .map(|iter| ChapterEntry::new(store, iter)) 244 | } else { 245 | None 246 | } 247 | } 248 | None => None, 249 | } 250 | } 251 | 252 | fn pick_previous(&self) -> Option> { 253 | match self.selected.as_ref() { 254 | Some(selected) => { 255 | let prev_iter = selected.clone(); 256 | if self.store.iter_previous(&prev_iter) { 257 | Some(ChapterEntry::new_owned(&self.store, prev_iter)) 258 | } else { 259 | // FIXME: with hierarchical tocs, this might be a case where 260 | // we should check whether the parent node contains something 261 | None 262 | } 263 | } 264 | None => self.store.get_iter_first().map(|iter| { 265 | let mut last_iter = iter.clone(); 266 | while self.store.iter_next(&iter) { 267 | last_iter = iter.clone(); 268 | } 269 | ChapterEntry::new_owned(&self.store, last_iter) 270 | }), 271 | } 272 | } 273 | 274 | fn add_unchecked(&self, ts: ChapterTimestamps, title: &str) -> gtk::TreeIter { 275 | self.store.insert_with_values( 276 | None, 277 | None, 278 | &[START_COL, END_COL, TITLE_COL, START_STR_COL, END_STR_COL], 279 | &[ 280 | &ts.start.as_u64(), 281 | &ts.end.as_u64(), 282 | &title, 283 | &ts.start.for_humans().to_string(), 284 | &ts.end.for_humans().to_string(), 285 | ], 286 | ) 287 | } 288 | 289 | fn select_by_ts(&mut self, ts: Timestamp) -> PositionStatus { 290 | let prev_sel_chapter = match self.selected_timestamps() { 291 | Some(sel_ts) => { 292 | if ts >= sel_ts.start && ts < sel_ts.end { 293 | // regular case: current timestamp in current chapter => don't change anything 294 | // this check is here to save time in the most frequent case 295 | return PositionStatus::ChapterNotChanged; 296 | } 297 | 298 | assert!(self.selected.is_some()); 299 | Some(ChapterIterStart { 300 | iter: self.selected.take().unwrap(), 301 | start: sel_ts.start, 302 | }) 303 | } 304 | None => None, 305 | }; 306 | 307 | if self.iter.is_some() { 308 | // not in selected_iter or selected_iter not defined yet 309 | // => search for a chapter matching current ts 310 | let mut searching_forward = true; 311 | loop { 312 | let iter_ts = self.iter_timestamps().expect("couldn't get start & end"); 313 | if ts >= iter_ts.start && ts < iter_ts.end { 314 | // current timestamp is in current chapter 315 | self.selected = self.iter.clone(); 316 | // ChapterChanged 317 | return prev_sel_chapter.into(); 318 | } else if ts >= iter_ts.end && searching_forward { 319 | // current timestamp is after iter and we were already searching forward 320 | let cur_iter = self.iter.clone(); 321 | self.next(); 322 | if self.iter.is_none() { 323 | // No more chapter => keep track of last iter: 324 | // in case of a seek back, we'll start from here 325 | self.iter = cur_iter; 326 | break; 327 | } 328 | } else if ts < iter_ts.start { 329 | // current timestamp before iter 330 | searching_forward = false; 331 | self.previous(); 332 | if self.iter.is_none() { 333 | // before first chapter 334 | self.iter = self.store.get_iter_first(); 335 | // ChapterChanged 336 | return prev_sel_chapter.into(); 337 | } 338 | } else { 339 | // in a gap between two chapters 340 | break; 341 | } 342 | } 343 | } 344 | 345 | // Couldn't find a chapter to select 346 | // consider that the chapter changed only if a chapter was selected before 347 | match prev_sel_chapter { 348 | Some(prev_sel_chapter) => Some(prev_sel_chapter).into(), 349 | None => PositionStatus::ChapterNotChanged, 350 | } 351 | } 352 | } 353 | 354 | pub struct ChapterTreeManager { 355 | tree: ChapterTree, 356 | } 357 | 358 | impl ChapterTreeManager { 359 | pub fn new(store: gtk::TreeStore) -> Self { 360 | ChapterTreeManager { 361 | tree: ChapterTree::new(store), 362 | } 363 | } 364 | 365 | pub fn init_treeview(&mut self, treeview: >k::TreeView) { 366 | treeview.set_model(Some(self.tree.store())); 367 | self.add_column( 368 | treeview, 369 | &gettext("Title"), 370 | TITLE_COL, 371 | ColumnOptions::CAN_EXPAND, 372 | ); 373 | self.add_column( 374 | treeview, 375 | &gettext("Start"), 376 | START_STR_COL, 377 | ColumnOptions::NONE, 378 | ); 379 | self.add_column(treeview, &gettext("End"), END_STR_COL, ColumnOptions::NONE); 380 | } 381 | 382 | fn add_column( 383 | &self, 384 | treeview: >k::TreeView, 385 | title: &str, 386 | col_id: u32, 387 | options: ColumnOptions, 388 | ) -> gtk::CellRendererText { 389 | let col = gtk::TreeViewColumn::new(); 390 | col.set_title(title); 391 | 392 | let renderer = gtk::CellRendererText::new(); 393 | 394 | col.pack_start(&renderer, true); 395 | col.add_attribute(&renderer, "text", col_id as i32); 396 | if options.contains(ColumnOptions::CAN_EXPAND) { 397 | col.set_min_width(70); 398 | col.set_expand(true); 399 | } else { 400 | // align right 401 | renderer.set_property_xalign(1f32); 402 | } 403 | treeview.append_column(&col); 404 | 405 | renderer 406 | } 407 | 408 | pub fn selected(&self) -> Option> { 409 | self.tree.selected_chapter() 410 | } 411 | 412 | pub fn selected_path(&self) -> Option { 413 | self.tree.selected_path() 414 | } 415 | 416 | pub fn chapter_from_path(&self, tree_path: >k::TreePath) -> Option> { 417 | self.tree.chapter_from_path(tree_path) 418 | } 419 | 420 | pub fn unselect(&mut self) { 421 | self.tree.unselect(); 422 | } 423 | 424 | pub fn clear(&mut self) { 425 | self.tree.clear(); 426 | } 427 | 428 | pub fn replace_with(&mut self, toc: &Option) { 429 | self.clear(); 430 | 431 | if let Some(ref toc) = *toc { 432 | let mut toc_visitor = TocVisitor::new(toc); 433 | if !toc_visitor.enter_chapters() { 434 | return; 435 | } 436 | 437 | // FIXME: handle hierarchical Tocs 438 | while let Some(chapter) = toc_visitor.next_chapter() { 439 | assert_eq!(gst::TocEntryType::Chapter, chapter.get_entry_type()); 440 | 441 | if let Some((start, end)) = chapter.get_start_stop_times() { 442 | let ts = ChapterTimestamps::new_from_u64(start as u64, end as u64); 443 | 444 | let title = chapter 445 | .get_tags() 446 | .and_then(|tags| { 447 | tags.get::() 448 | .and_then(|tag| tag.get().map(ToString::to_string)) 449 | }) 450 | .unwrap_or_else(get_default_chapter_title); 451 | 452 | self.tree.add_unchecked(ts, &title); 453 | } 454 | } 455 | } 456 | 457 | self.tree.rewind(); 458 | } 459 | 460 | pub fn iter(&self) -> Iter<'_> { 461 | self.tree.new_iter() 462 | } 463 | 464 | // Update chapter according to the given ts 465 | pub fn update_ts(&mut self, ts: Timestamp) -> PositionStatus { 466 | self.tree.select_by_ts(ts) 467 | } 468 | 469 | pub fn pick_next(&self) -> Option> { 470 | self.tree.pick_next() 471 | } 472 | 473 | pub fn pick_previous(&self) -> Option> { 474 | self.tree.pick_previous() 475 | } 476 | } 477 | 478 | pub struct Iter<'store> { 479 | store: &'store gtk::TreeStore, 480 | iter: Option, 481 | is_first: bool, 482 | } 483 | 484 | impl<'store> Iter<'store> { 485 | fn new(store: &'store gtk::TreeStore) -> Self { 486 | Iter { 487 | store, 488 | iter: None, 489 | is_first: true, 490 | } 491 | } 492 | } 493 | 494 | impl<'store> Iterator for Iter<'store> { 495 | type Item = ChapterEntry<'store>; 496 | 497 | fn next(&mut self) -> Option { 498 | if !self.is_first { 499 | if let Some(iter) = self.iter.as_mut() { 500 | if !self.store.iter_next(iter) { 501 | self.iter = None; 502 | } 503 | } 504 | } else { 505 | self.iter = self.store.get_iter_first(); 506 | self.is_first = false; 507 | } 508 | 509 | self.iter 510 | .clone() 511 | .map(|iter| ChapterEntry::new_owned(&self.store, iter)) 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /src/ui/image.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::{Cell, RefCell}, 3 | rc::Rc, 4 | }; 5 | 6 | // This is from https://github.com/gtk-rs/examples/blob/master/src/bin/cairo_threads.rs 7 | // Helper struct that allows passing the pixels to the Cairo image surface and once the 8 | // image surface is destroyed the pixels will be stored in the return_location. 9 | // 10 | // This allows us to give temporary ownership of the pixels to the Cairo surface and later 11 | // retrieve them back in a safe way while ensuring that nothing else still has access to 12 | // it. 13 | struct ImageHolder { 14 | pixels: Option>, 15 | return_location: Rc>>>, 16 | } 17 | 18 | // This stores the pixels back into the return_location as now nothing 19 | // references the pixels anymore 20 | impl Drop for ImageHolder { 21 | fn drop(&mut self) { 22 | *self.return_location.borrow_mut() = Some(self.pixels.take().expect("Holding no image")); 23 | } 24 | } 25 | 26 | impl AsMut<[u8]> for ImageHolder { 27 | fn as_mut(&mut self) -> &mut [u8] { 28 | self.pixels.as_mut().expect("Holding no image").as_mut() 29 | } 30 | } 31 | 32 | // This is mostly from https://github.com/gtk-rs/examples/blob/master/src/bin/cairo_threads.rs 33 | // This stores a heap allocated byte array for the pixels for each of our RGB24 34 | // images, can be sent safely between threads and can be temporarily converted to a Cairo image 35 | // surface for drawing operations 36 | // 37 | // Note that the alignments here a forced to Cairo's requirements 38 | // Force dimensions to `u16` so that we are sure to fit in the `i32` dimensions 39 | pub struct Image { 40 | // Use a Cell to hide internal implementation details due to Cairo surface ownership 41 | pixels: Cell>>, 42 | pub width: i32, 43 | pub height: i32, 44 | stride: i32, 45 | } 46 | 47 | impl Image { 48 | pub fn from_unknown(input: &[u8]) -> Result { 49 | let image = image::load_from_memory(input) 50 | .map_err(|err| format!("Error loading image: {:?}", err))?; 51 | 52 | match image.as_rgb8().as_ref() { 53 | Some(rgb_image) => { 54 | // Align to Cairo's needs: 4 bytes per pixel 55 | // When converting to RGB8, image crate uses 3 bytes in different order 56 | let width = rgb_image.width(); 57 | let height = rgb_image.height(); 58 | 59 | if width > i32::max_value() as u32 { 60 | return Err(format!("Image width {} is too large", width)); 61 | } 62 | if height > i32::max_value() as u32 { 63 | return Err(format!("Image height {} is too large", height)); 64 | } 65 | 66 | let stride = cairo::Format::Rgb24 67 | .stride_for_width(width) 68 | .map_err(|status| { 69 | format!("Couldn't compute stride for width {}: {:?}", width, status) 70 | })?; 71 | 72 | let width = width as i32; 73 | let height = height as i32; 74 | 75 | let mut pixels = Vec::with_capacity(height as usize * stride as usize); 76 | 77 | for pixel in rgb_image.chunks(3) { 78 | pixels.push(pixel[2]); 79 | pixels.push(pixel[1]); 80 | pixels.push(pixel[0]); 81 | pixels.push(0); 82 | } 83 | 84 | Ok(Image { 85 | pixels: Cell::new(Some(pixels.into())), 86 | width, 87 | height, 88 | stride, 89 | }) 90 | } 91 | None => Err("Error converting image to raw RGB".to_owned()), 92 | } 93 | } 94 | 95 | pub fn width(&self) -> i32 { 96 | self.width 97 | } 98 | 99 | pub fn height(&self) -> i32 { 100 | self.height 101 | } 102 | 103 | #[allow(dead_code)] 104 | pub fn stride(&self) -> i32 { 105 | self.stride 106 | } 107 | 108 | // Calls the given closure with a temporary Cairo image surface. After the closure has returned 109 | // there must be no further references to the surface. 110 | pub fn with_surface_external_context( 111 | &self, 112 | cr: &cairo::Context, 113 | func: F, 114 | ) { 115 | // Temporary move out the pixels 116 | let pixels = self.pixels.take(); 117 | assert!(pixels.is_some()); 118 | 119 | // A new return location that is then passed to our helper struct below 120 | let return_location = Rc::new(RefCell::new(None)); 121 | { 122 | let holder = ImageHolder { 123 | pixels, 124 | return_location: Rc::clone(&return_location), 125 | }; 126 | 127 | // The surface will own the image for the scope of the block below 128 | { 129 | let surface = cairo::ImageSurface::create_for_data( 130 | holder, 131 | cairo::Format::Rgb24, 132 | self.width, 133 | self.height, 134 | self.stride, 135 | ) 136 | .expect("Can't create surface"); 137 | func(cr, &surface); 138 | 139 | // Release the reference to the surface. 140 | // This is required otherwise the surface is not released and the pixels 141 | // are not moved back to `return_location` 142 | cr.set_source_rgba(0.0, 0.0, 0.0, 0.0); 143 | } 144 | 145 | // Now the surface will be destroyed and the pixels are stored in the return_location 146 | } 147 | 148 | // Move the pixels back 149 | let pixels = return_location.borrow_mut().take(); 150 | assert!(pixels.is_some()); 151 | 152 | self.pixels.set(pixels); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/ui/info_bar_controller.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | 3 | use gio::prelude::*; 4 | use glib::clone; 5 | use gtk::prelude::*; 6 | 7 | use log::{error, info}; 8 | 9 | use std::borrow::Borrow; 10 | 11 | use super::{UIEventSender, UIFocusContext}; 12 | 13 | pub struct InfoBarController { 14 | info_bar: gtk::InfoBar, 15 | revealer: gtk::Revealer, 16 | label: gtk::Label, 17 | ui_event: UIEventSender, 18 | } 19 | 20 | impl InfoBarController { 21 | pub fn new(app: >k::Application, builder: >k::Builder, ui_event: &UIEventSender) -> Self { 22 | let info_bar: gtk::InfoBar = builder.get_object("info_bar").unwrap(); 23 | info_bar.add_button(&gettext("No"), gtk::ResponseType::No); 24 | info_bar.add_button(&gettext("Yes to all"), gtk::ResponseType::Apply); 25 | info_bar.add_button(&gettext("Cancel"), gtk::ResponseType::Cancel); 26 | info_bar.set_default_response(gtk::ResponseType::Yes); 27 | 28 | let revealer: gtk::Revealer = builder.get_object("info_bar-revealer").unwrap(); 29 | 30 | let close_info_bar_action = gio::SimpleAction::new("close_info_bar", None); 31 | app.add_action(&close_info_bar_action); 32 | app.set_accels_for_action("app.close_info_bar", &["Escape"]); 33 | 34 | info_bar.connect_response(clone!(@strong ui_event => move |_, _| { 35 | ui_event.hide_info_bar(); 36 | ui_event.restore_context(); 37 | })); 38 | 39 | if gst::init().is_ok() { 40 | close_info_bar_action 41 | .connect_activate(clone!(@strong info_bar => move |_, _| info_bar.emit_close())); 42 | } else { 43 | close_info_bar_action.connect_activate(clone!(@strong ui_event => move |_, _| { 44 | ui_event.quit(); 45 | })); 46 | 47 | info_bar.connect_response(clone!(@strong ui_event => move |_, _| { 48 | ui_event.quit(); 49 | })); 50 | } 51 | 52 | let ui_event = ui_event.clone(); 53 | InfoBarController { 54 | info_bar, 55 | revealer, 56 | label: builder.get_object("info_bar-lbl").unwrap(), 57 | ui_event, 58 | } 59 | } 60 | 61 | pub fn hide(&self) { 62 | self.revealer.set_reveal_child(false); 63 | } 64 | 65 | pub fn show_message>(&mut self, type_: gtk::MessageType, message: Msg) { 66 | self.info_bar.set_show_close_button(true); 67 | self.info_bar.set_message_type(type_); 68 | self.label.set_label(message.borrow()); 69 | self.revealer.set_reveal_child(true); 70 | 71 | self.ui_event.temporarily_switch_to(UIFocusContext::InfoBar); 72 | } 73 | 74 | pub fn show_error>(&mut self, message: Msg) { 75 | error!("{}", message.borrow()); 76 | self.show_message(gtk::MessageType::Error, message); 77 | } 78 | 79 | pub fn show_info>(&mut self, message: Msg) { 80 | info!("{}", message.borrow()); 81 | self.show_message(gtk::MessageType::Info, message); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/ui/info_controller.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use gtk::prelude::*; 3 | use log::{debug, info, warn}; 4 | 5 | use std::fs::File; 6 | 7 | use crate::{ 8 | application::CONFIG, 9 | media::{PlaybackPipeline, Timestamp}, 10 | metadata, 11 | metadata::{Duration, MediaInfo, Timestamp4Humans}, 12 | }; 13 | 14 | use super::{ 15 | ChapterTreeManager, ControllerState, Image, PositionStatus, UIController, UIEventSender, 16 | }; 17 | 18 | const EMPTY_REPLACEMENT: &str = "-"; 19 | const GO_TO_PREV_CHAPTER_THRESHOLD: Duration = Duration::from_secs(1); 20 | pub const SEEK_STEP: Duration = Duration::from_nanos(2_500_000_000); 21 | 22 | enum ThumbnailState { 23 | Blocked, 24 | Unblocked, 25 | } 26 | 27 | struct Thumbnail { 28 | drawingarea: gtk::DrawingArea, 29 | signal_handler_id: Option, 30 | state: ThumbnailState, 31 | } 32 | 33 | impl Thumbnail { 34 | fn new(drawingarea: >k::DrawingArea, draw_cb: D) -> Self 35 | where 36 | D: Fn(>k::DrawingArea, &cairo::Context) -> Inhibit + 'static, 37 | { 38 | let signal_handler_id = drawingarea.connect_draw(draw_cb); 39 | glib::signal_handler_block(drawingarea, &signal_handler_id); 40 | 41 | Thumbnail { 42 | drawingarea: drawingarea.clone(), 43 | signal_handler_id: Some(signal_handler_id), 44 | state: ThumbnailState::Blocked, 45 | } 46 | } 47 | 48 | fn block(&mut self) { 49 | if let ThumbnailState::Unblocked = self.state { 50 | glib::signal_handler_block(&self.drawingarea, self.signal_handler_id.as_ref().unwrap()); 51 | self.state = ThumbnailState::Blocked; 52 | } 53 | } 54 | 55 | fn unblock(&mut self) { 56 | if let ThumbnailState::Blocked = self.state { 57 | glib::signal_handler_unblock( 58 | &self.drawingarea, 59 | self.signal_handler_id.as_ref().unwrap(), 60 | ); 61 | self.state = ThumbnailState::Unblocked; 62 | } 63 | } 64 | } 65 | 66 | impl Drop for Thumbnail { 67 | fn drop(&mut self) { 68 | glib::signal_handler_disconnect(&self.drawingarea, self.signal_handler_id.take().unwrap()); 69 | } 70 | } 71 | 72 | pub struct InfoController { 73 | ui_event: UIEventSender, 74 | 75 | pub(super) info_container: gtk::Grid, 76 | pub(super) show_chapters_btn: gtk::ToggleButton, 77 | 78 | pub(super) drawingarea: gtk::DrawingArea, 79 | 80 | title_lbl: gtk::Label, 81 | artist_lbl: gtk::Label, 82 | container_lbl: gtk::Label, 83 | audio_codec_lbl: gtk::Label, 84 | video_codec_lbl: gtk::Label, 85 | position_lbl: gtk::Label, 86 | duration_lbl: gtk::Label, 87 | 88 | pub(super) timeline_scale: gtk::Scale, 89 | pub(super) repeat_btn: gtk::ToggleToolButton, 90 | 91 | pub(super) chapter_treeview: gtk::TreeView, 92 | pub(super) next_chapter_action: gio::SimpleAction, 93 | pub(super) previous_chapter_action: gio::SimpleAction, 94 | 95 | thumbnail: Option, 96 | 97 | pub(super) chapter_manager: ChapterTreeManager, 98 | 99 | duration: Duration, 100 | pub(super) repeat_chapter: bool, 101 | } 102 | 103 | impl UIController for InfoController { 104 | fn new_media(&mut self, pipeline: &PlaybackPipeline) { 105 | let toc_extensions = metadata::Factory::get_extensions(); 106 | 107 | { 108 | // check the presence of a toc file 109 | let mut toc_candidates = 110 | toc_extensions 111 | .into_iter() 112 | .filter_map(|(extension, format)| { 113 | let path = pipeline 114 | .info 115 | .path 116 | .with_file_name(&format!("{}.{}", pipeline.info.name, extension)); 117 | if path.is_file() { 118 | Some((path, format)) 119 | } else { 120 | None 121 | } 122 | }); 123 | 124 | self.duration = pipeline.info.duration; 125 | self.timeline_scale 126 | .set_range(0f64, pipeline.info.duration.as_f64()); 127 | self.duration_lbl 128 | .set_label(&Timestamp4Humans::from_duration(pipeline.info.duration).to_string()); 129 | 130 | let thumbnail = pipeline.info.media_image().and_then(|image| { 131 | image.get_buffer().and_then(|image_buffer| { 132 | image_buffer.map_readable().ok().and_then(|image_map| { 133 | Image::from_unknown(image_map.as_slice()) 134 | .map_err(|err| warn!("{}", err)) 135 | .ok() 136 | }) 137 | }) 138 | }); 139 | 140 | if let Some(thumbnail) = thumbnail { 141 | self.thumbnail = Some(Thumbnail::new( 142 | &self.drawingarea, 143 | move |drawingarea, cairo_ctx| { 144 | Self::draw_thumbnail(&thumbnail, drawingarea, cairo_ctx); 145 | Inhibit(true) 146 | }, 147 | )); 148 | } 149 | 150 | self.container_lbl 151 | .set_label(pipeline.info.container().unwrap_or(EMPTY_REPLACEMENT)); 152 | 153 | let extern_toc = toc_candidates 154 | .next() 155 | .and_then(|(toc_path, format)| match File::open(toc_path.clone()) { 156 | Ok(mut toc_file) => { 157 | match metadata::Factory::get_reader(format) 158 | .read(&pipeline.info, &mut toc_file) 159 | { 160 | Ok(Some(toc)) => Some(toc), 161 | Ok(None) => { 162 | let msg = gettext("No toc in file \"{}\"").replacen( 163 | "{}", 164 | toc_path.file_name().unwrap().to_str().unwrap(), 165 | 1, 166 | ); 167 | info!("{}", msg); 168 | self.ui_event.show_info(msg); 169 | None 170 | } 171 | Err(err) => { 172 | self.ui_event.show_error( 173 | gettext("Error opening toc file \"{}\":\n{}") 174 | .replacen( 175 | "{}", 176 | toc_path.file_name().unwrap().to_str().unwrap(), 177 | 1, 178 | ) 179 | .replacen("{}", &err, 1), 180 | ); 181 | None 182 | } 183 | } 184 | } 185 | Err(_) => { 186 | self.ui_event 187 | .show_error(gettext("Failed to open toc file.")); 188 | None 189 | } 190 | }); 191 | 192 | if extern_toc.is_some() { 193 | self.chapter_manager.replace_with(&extern_toc); 194 | } else { 195 | self.chapter_manager.replace_with(&pipeline.info.toc); 196 | } 197 | } 198 | 199 | self.update_marks(); 200 | 201 | self.repeat_btn.set_sensitive(true); 202 | if let Some(sel_chapter) = self.chapter_manager.selected() { 203 | // position is in a chapter => select it 204 | self.chapter_treeview 205 | .get_selection() 206 | .select_iter(sel_chapter.iter()); 207 | } 208 | 209 | self.next_chapter_action.set_enabled(true); 210 | self.previous_chapter_action.set_enabled(true); 211 | 212 | self.ui_event.update_focus(); 213 | } 214 | 215 | fn cleanup(&mut self) { 216 | self.title_lbl.set_text(""); 217 | self.artist_lbl.set_text(""); 218 | self.container_lbl.set_text(""); 219 | self.audio_codec_lbl.set_text(""); 220 | self.video_codec_lbl.set_text(""); 221 | self.position_lbl.set_text("00:00.000"); 222 | self.duration_lbl.set_text("00:00.000"); 223 | let _ = self.thumbnail.take(); 224 | self.chapter_treeview.get_selection().unselect_all(); 225 | self.chapter_manager.clear(); 226 | self.next_chapter_action.set_enabled(false); 227 | self.previous_chapter_action.set_enabled(false); 228 | self.timeline_scale.clear_marks(); 229 | self.timeline_scale.set_value(0f64); 230 | self.duration = Duration::default(); 231 | } 232 | 233 | fn streams_changed(&mut self, info: &MediaInfo) { 234 | match info.media_artist() { 235 | Some(artist) => self.artist_lbl.set_label(artist), 236 | None => self.artist_lbl.set_label(EMPTY_REPLACEMENT), 237 | } 238 | match info.media_title() { 239 | Some(title) => self.title_lbl.set_label(title), 240 | None => self.title_lbl.set_label(EMPTY_REPLACEMENT), 241 | } 242 | 243 | self.audio_codec_lbl 244 | .set_label(info.streams.audio_codec().unwrap_or(EMPTY_REPLACEMENT)); 245 | self.video_codec_lbl 246 | .set_label(info.streams.video_codec().unwrap_or(EMPTY_REPLACEMENT)); 247 | 248 | if !info.streams.is_video_selected() { 249 | debug!("streams_changed showing thumbnail"); 250 | if let Some(thumbnail) = self.thumbnail.as_mut() { 251 | thumbnail.unblock(); 252 | } 253 | self.drawingarea.show(); 254 | self.drawingarea.queue_draw(); 255 | } else { 256 | if let Some(thumbnail) = self.thumbnail.as_mut() { 257 | thumbnail.block(); 258 | } 259 | self.drawingarea.hide(); 260 | } 261 | } 262 | 263 | fn grab_focus(&self) { 264 | self.chapter_treeview.grab_focus(); 265 | 266 | match self.chapter_manager.selected_path() { 267 | Some(sel_path) => { 268 | self.chapter_treeview 269 | .set_cursor(&sel_path, None::<>k::TreeViewColumn>, false); 270 | self.chapter_treeview.grab_default(); 271 | } 272 | None => { 273 | // Set the cursor to an uninitialized path to unselect 274 | self.chapter_treeview.set_cursor( 275 | >k::TreePath::new(), 276 | None::<>k::TreeViewColumn>, 277 | false, 278 | ); 279 | } 280 | } 281 | } 282 | } 283 | 284 | impl InfoController { 285 | pub fn new(builder: >k::Builder, ui_event: UIEventSender) -> Self { 286 | let mut chapter_manager = 287 | ChapterTreeManager::new(builder.get_object("chapters-tree-store").unwrap()); 288 | let chapter_treeview: gtk::TreeView = builder.get_object("chapter-treeview").unwrap(); 289 | chapter_manager.init_treeview(&chapter_treeview); 290 | 291 | let mut ctrl = InfoController { 292 | ui_event, 293 | 294 | info_container: builder.get_object("info-chapter_list-grid").unwrap(), 295 | show_chapters_btn: builder.get_object("show_chapters-toggle").unwrap(), 296 | 297 | drawingarea: builder.get_object("thumbnail-drawingarea").unwrap(), 298 | 299 | title_lbl: builder.get_object("title-lbl").unwrap(), 300 | artist_lbl: builder.get_object("artist-lbl").unwrap(), 301 | container_lbl: builder.get_object("container-lbl").unwrap(), 302 | audio_codec_lbl: builder.get_object("audio_codec-lbl").unwrap(), 303 | video_codec_lbl: builder.get_object("video_codec-lbl").unwrap(), 304 | position_lbl: builder.get_object("position-lbl").unwrap(), 305 | duration_lbl: builder.get_object("duration-lbl").unwrap(), 306 | 307 | timeline_scale: builder.get_object("timeline-scale").unwrap(), 308 | repeat_btn: builder.get_object("repeat-toolbutton").unwrap(), 309 | 310 | chapter_treeview, 311 | next_chapter_action: gio::SimpleAction::new("next_chapter", None), 312 | previous_chapter_action: gio::SimpleAction::new("previous_chapter", None), 313 | 314 | thumbnail: None, 315 | 316 | chapter_manager, 317 | 318 | duration: Duration::default(), 319 | repeat_chapter: false, 320 | }; 321 | 322 | ctrl.cleanup(); 323 | 324 | // Show chapters toggle 325 | if CONFIG.read().unwrap().ui.is_chapters_list_hidden { 326 | ctrl.show_chapters_btn.set_active(false); 327 | ctrl.info_container.hide(); 328 | } 329 | 330 | ctrl.show_chapters_btn.set_sensitive(true); 331 | 332 | ctrl 333 | } 334 | 335 | pub fn draw_thumbnail( 336 | image: &Image, 337 | drawingarea: >k::DrawingArea, 338 | cairo_ctx: &cairo::Context, 339 | ) { 340 | let allocation = drawingarea.get_allocation(); 341 | let alloc_width_f: f64 = allocation.width.into(); 342 | let alloc_height_f: f64 = allocation.height.into(); 343 | 344 | let image_width_f: f64 = image.width().into(); 345 | let image_height_f: f64 = image.height().into(); 346 | 347 | let alloc_ratio = alloc_width_f / alloc_height_f; 348 | let image_ratio = image_width_f / image_height_f; 349 | let scale = if image_ratio < alloc_ratio { 350 | alloc_height_f / image_height_f 351 | } else { 352 | alloc_width_f / image_width_f 353 | }; 354 | let x = (alloc_width_f / scale - image_width_f).abs() / 2f64; 355 | let y = (alloc_height_f / scale - image_height_f).abs() / 2f64; 356 | 357 | image.with_surface_external_context(cairo_ctx, |cr, surface| { 358 | cr.scale(scale, scale); 359 | cr.set_source_surface(surface, x, y); 360 | cr.paint(); 361 | }) 362 | } 363 | 364 | fn update_marks(&self) { 365 | self.timeline_scale.clear_marks(); 366 | 367 | let timeline_scale = self.timeline_scale.clone(); 368 | self.chapter_manager.iter().for_each(move |chapter| { 369 | timeline_scale.add_mark(chapter.start().as_f64(), gtk::PositionType::Top, None); 370 | }); 371 | } 372 | 373 | fn repeat_at(&self, ts: Timestamp) { 374 | self.ui_event.seek(ts, gst::SeekFlags::ACCURATE) 375 | } 376 | 377 | pub fn tick(&mut self, ts: Timestamp, state: ControllerState) { 378 | self.timeline_scale.set_value(ts.as_f64()); 379 | self.position_lbl 380 | .set_text(&Timestamp4Humans::from_nano(ts.as_u64()).to_string()); 381 | 382 | let mut position_status = self.chapter_manager.update_ts(ts); 383 | 384 | if self.repeat_chapter { 385 | // repeat is activated 386 | if let ControllerState::EosPlaying = state { 387 | // postpone chapter selection change until media has synchronized 388 | position_status = PositionStatus::ChapterNotChanged; 389 | self.repeat_at(Timestamp::default()); 390 | } else if let PositionStatus::ChapterChanged { prev_chapter } = &position_status { 391 | if let Some(prev_chapter) = prev_chapter { 392 | // reset position_status because we will be looping on current chapter 393 | let prev_start = prev_chapter.start; 394 | position_status = PositionStatus::ChapterNotChanged; 395 | 396 | // unselect chapter in order to avoid tracing change to current timestamp 397 | self.chapter_manager.unselect(); 398 | self.repeat_at(prev_start); 399 | } 400 | } 401 | } 402 | 403 | if let PositionStatus::ChapterChanged { prev_chapter } = position_status { 404 | // let go the mutable reference on `self.chapter_manager` 405 | match self.chapter_manager.selected() { 406 | Some(sel_chapter) => { 407 | // timestamp is in a chapter => select it 408 | self.chapter_treeview 409 | .get_selection() 410 | .select_iter(sel_chapter.iter()); 411 | } 412 | None => 413 | // timestamp is not in any chapter 414 | { 415 | if let Some(prev_chapter) = prev_chapter { 416 | // but a previous chapter was selected => unselect it 417 | self.chapter_treeview 418 | .get_selection() 419 | .unselect_iter(&prev_chapter.iter); 420 | } 421 | } 422 | } 423 | 424 | self.ui_event.update_focus(); 425 | } 426 | } 427 | 428 | pub fn seek(&mut self, target: Timestamp, state: ControllerState) { 429 | self.tick(target, state); 430 | } 431 | 432 | pub fn toggle_chapter_list(&self, must_show: bool) { 433 | CONFIG.write().unwrap().ui.is_chapters_list_hidden = must_show; 434 | 435 | if must_show { 436 | self.info_container.hide(); 437 | } else { 438 | self.info_container.show(); 439 | } 440 | } 441 | 442 | pub fn previous_chapter(&self, cur_ts: Timestamp) -> Option { 443 | let cur_start = self 444 | .chapter_manager 445 | .selected() 446 | .map(|sel_chapter| sel_chapter.start()); 447 | let prev_start = self 448 | .chapter_manager 449 | .pick_previous() 450 | .map(|prev_chapter| prev_chapter.start()); 451 | 452 | match (cur_start, prev_start) { 453 | (Some(cur_start), prev_start_opt) => { 454 | if cur_ts > cur_start + GO_TO_PREV_CHAPTER_THRESHOLD { 455 | Some(cur_start) 456 | } else { 457 | prev_start_opt 458 | } 459 | } 460 | (None, prev_start_opt) => prev_start_opt, 461 | } 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/ui/info_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use gio::prelude::*; 2 | use glib::clone; 3 | use gtk::prelude::*; 4 | 5 | use std::{cell::RefCell, rc::Rc}; 6 | 7 | use super::{InfoController, MainController, UIDispatcher, UIEventSender, UIFocusContext}; 8 | 9 | pub struct InfoDispatcher; 10 | impl UIDispatcher for InfoDispatcher { 11 | type Controller = InfoController; 12 | 13 | fn setup( 14 | info_ctrl: &mut InfoController, 15 | _main_ctrl_rc: &Rc>, 16 | app: >k::Application, 17 | ui_event: &UIEventSender, 18 | ) { 19 | // Register Toggle show chapters list action 20 | let toggle_show_list = gio::SimpleAction::new("toggle_show_list", None); 21 | app.add_action(&toggle_show_list); 22 | let show_chapters_btn = info_ctrl.show_chapters_btn.clone(); 23 | toggle_show_list.connect_activate(move |_, _| { 24 | show_chapters_btn.set_active(!show_chapters_btn.get_active()); 25 | }); 26 | 27 | info_ctrl.show_chapters_btn.connect_toggled(clone!( 28 | @strong ui_event => move |toggle_button| { 29 | ui_event.toggle_chapter_list(!toggle_button.get_active()); 30 | } 31 | )); 32 | 33 | // Scale seek 34 | info_ctrl.timeline_scale.connect_change_value( 35 | clone!(@strong ui_event => move |_, _, value| { 36 | ui_event.seek((value as u64).into(), gst::SeekFlags::KEY_UNIT); 37 | Inhibit(true) 38 | }), 39 | ); 40 | 41 | // TreeView seek 42 | info_ctrl.chapter_treeview.connect_row_activated( 43 | clone!(@strong ui_event => move |_, tree_path, _| { 44 | ui_event.chapter_clicked(tree_path.clone()); 45 | }), 46 | ); 47 | 48 | // Register Toggle repeat current chapter action 49 | let toggle_repeat_chapter = gio::SimpleAction::new("toggle_repeat_chapter", None); 50 | app.add_action(&toggle_repeat_chapter); 51 | let repeat_btn = info_ctrl.repeat_btn.clone(); 52 | toggle_repeat_chapter.connect_activate(move |_, _| { 53 | repeat_btn.set_active(!repeat_btn.get_active()); 54 | }); 55 | 56 | info_ctrl 57 | .repeat_btn 58 | .connect_clicked(clone!(@strong ui_event => move |button| { 59 | ui_event.toggle_repeat(button.get_active()); 60 | })); 61 | 62 | // Register next chapter action 63 | app.add_action(&info_ctrl.next_chapter_action); 64 | info_ctrl 65 | .next_chapter_action 66 | .connect_activate(clone!(@strong ui_event => move |_, _| { 67 | ui_event.next_chapter(); 68 | })); 69 | 70 | // Register previous chapter action 71 | app.add_action(&info_ctrl.previous_chapter_action); 72 | info_ctrl.previous_chapter_action.connect_activate(clone!( 73 | @strong ui_event => move |_, _| { 74 | ui_event.previous_chapter(); 75 | } 76 | )); 77 | 78 | // Register Step forward action 79 | let step_forward = gio::SimpleAction::new("step_forward", None); 80 | app.add_action(&step_forward); 81 | step_forward.connect_activate(clone!( 82 | @strong ui_event => move |_, _| { 83 | ui_event.step_forward(); 84 | } 85 | )); 86 | app.set_accels_for_action("app.step_forward", &["Right"]); 87 | 88 | // Register Step back action 89 | let step_back = gio::SimpleAction::new("step_back", None); 90 | app.add_action(&step_back); 91 | step_back.connect_activate(clone!( 92 | @strong ui_event => move |_, _| { 93 | ui_event.step_back(); 94 | } 95 | )); 96 | 97 | app.set_accels_for_action("app.step_back", &["Left"]); 98 | } 99 | 100 | fn bind_accels_for(ctx: UIFocusContext, app: >k::Application) { 101 | match ctx { 102 | UIFocusContext::PlaybackPage => { 103 | app.set_accels_for_action("app.toggle_show_list", &["l"]); 104 | app.set_accels_for_action("app.toggle_repeat_chapter", &["r"]); 105 | } 106 | UIFocusContext::StreamsPage => { 107 | app.set_accels_for_action("app.toggle_show_list", &["l"]); 108 | app.set_accels_for_action("app.toggle_repeat_chapter", &["r"]); 109 | } 110 | UIFocusContext::InfoBar => { 111 | app.set_accels_for_action("app.toggle_show_list", &[]); 112 | app.set_accels_for_action("app.toggle_repeat_chapter", &[]); 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/ui/main_controller.rs: -------------------------------------------------------------------------------- 1 | use futures::future::{abortable, AbortHandle, LocalBoxFuture}; 2 | use futures::prelude::*; 3 | 4 | use gettextrs::{gettext, ngettext}; 5 | 6 | use glib::clone; 7 | use gtk::prelude::*; 8 | 9 | use log::error; 10 | 11 | use std::{borrow::ToOwned, cell::RefCell, path::PathBuf, rc::Rc, sync::Arc}; 12 | 13 | use crate::{ 14 | application::{CommandLineArguments, APP_ID, APP_PATH, CONFIG}, 15 | media::{ 16 | MediaMessage, MissingPlugins, PlaybackPipeline, SeekError, SelectStreamsError, Timestamp, 17 | }, 18 | }; 19 | 20 | use super::{ 21 | spawn, ui_event, InfoController, MainDispatcher, PerspectiveController, StreamsController, 22 | UIController, UIEventSender, VideoController, 23 | }; 24 | 25 | const PAUSE_ICON: &str = "media-playback-pause-symbolic"; 26 | const PLAYBACK_ICON: &str = "media-playback-start-symbolic"; 27 | 28 | #[derive(Clone, Copy, Debug, PartialEq)] 29 | pub enum ControllerState { 30 | EosPaused, 31 | EosPlaying, 32 | Paused, 33 | PendingSelectMediaDecision, 34 | Playing, 35 | Stopped, 36 | } 37 | 38 | pub struct MainController { 39 | pub(super) window: gtk::ApplicationWindow, 40 | pub(super) window_delete_id: Option, 41 | 42 | header_bar: gtk::HeaderBar, 43 | pub(super) open_btn: gtk::Button, 44 | pub(super) display_page: gtk::Box, 45 | pub(super) play_pause_btn: gtk::ToolButton, 46 | file_dlg: gtk::FileChooserNative, 47 | 48 | pub(super) ui_event: UIEventSender, 49 | 50 | pub(super) perspective_ctrl: PerspectiveController, 51 | pub(super) video_ctrl: VideoController, 52 | pub(super) info_ctrl: InfoController, 53 | pub(super) streams_ctrl: StreamsController, 54 | 55 | pub(super) pipeline: Option, 56 | pub(super) state: ControllerState, 57 | 58 | media_msg_abort_handle: Option, 59 | 60 | pub(super) new_tracker: Option LocalBoxFuture<'static, ()>>>, 61 | tracker_abort_handle: Option, 62 | } 63 | 64 | impl MainController { 65 | pub fn setup(app: >k::Application, args: &CommandLineArguments) { 66 | let builder = 67 | gtk::Builder::from_resource(&format!("{}/{}", *APP_PATH, "media-toc-player.ui")); 68 | 69 | let window: gtk::ApplicationWindow = builder.get_object("application-window").unwrap(); 70 | window.set_application(Some(app)); 71 | window.set_title(&gettext("media-toc player")); 72 | 73 | let (ui_event, ui_event_receiver) = ui_event::new_pair(); 74 | 75 | let file_dlg = gtk::FileChooserNativeBuilder::new() 76 | .title(&gettext("Open a media file")) 77 | .transient_for(&window) 78 | .modal(true) 79 | .accept_label(&gettext("Open")) 80 | .cancel_label(&gettext("Cancel")) 81 | .build(); 82 | 83 | file_dlg.connect_response(clone!(@strong ui_event => move |file_dlg, response| { 84 | file_dlg.hide(); 85 | if response == gtk::ResponseType::Accept { 86 | if let Some(path) = file_dlg.get_filename() { 87 | ui_event.open_media(path); 88 | return; 89 | } 90 | } 91 | 92 | ui_event.cancel_select_media(); 93 | })); 94 | 95 | let gst_init_res = gst::init(); 96 | 97 | let main_ctrl_rc = Rc::new(RefCell::new(MainController { 98 | window: window.clone(), 99 | window_delete_id: None, 100 | 101 | header_bar: builder.get_object("header-bar").unwrap(), 102 | open_btn: builder.get_object("open-btn").unwrap(), 103 | display_page: builder.get_object("video-container").unwrap(), 104 | play_pause_btn: builder.get_object("play_pause-toolbutton").unwrap(), 105 | file_dlg, 106 | 107 | ui_event: ui_event.clone(), 108 | 109 | perspective_ctrl: PerspectiveController::new(&builder), 110 | video_ctrl: VideoController::new(&builder, args), 111 | info_ctrl: InfoController::new(&builder, ui_event.clone()), 112 | streams_ctrl: StreamsController::new(&builder), 113 | 114 | pipeline: None, 115 | state: ControllerState::Stopped, 116 | 117 | media_msg_abort_handle: None, 118 | 119 | new_tracker: None, 120 | tracker_abort_handle: None, 121 | })); 122 | 123 | let mut main_ctrl = main_ctrl_rc.borrow_mut(); 124 | MainDispatcher::setup( 125 | &mut main_ctrl, 126 | &main_ctrl_rc, 127 | app, 128 | &window, 129 | &builder, 130 | ui_event_receiver, 131 | ); 132 | 133 | if gst_init_res.is_ok() { 134 | { 135 | let config = CONFIG.read().unwrap(); 136 | if config.ui.width > 0 && config.ui.height > 0 { 137 | main_ctrl.window.resize(config.ui.width, config.ui.height); 138 | } 139 | 140 | main_ctrl.open_btn.set_sensitive(true); 141 | } 142 | 143 | ui_event.show_all(); 144 | 145 | if let Some(input_file) = args.input_file.to_owned() { 146 | main_ctrl.ui_event.open_media(input_file); 147 | } 148 | } else { 149 | ui_event.show_all(); 150 | } 151 | } 152 | 153 | pub fn ui_event(&self) -> &UIEventSender { 154 | &self.ui_event 155 | } 156 | 157 | pub fn about(&self) { 158 | let dialog = gtk::AboutDialog::new(); 159 | dialog.set_modal(true); 160 | dialog.set_transient_for(Some(&self.window)); 161 | 162 | dialog.set_program_name(env!("CARGO_PKG_NAME")); 163 | dialog.set_logo_icon_name(Some(&APP_ID)); 164 | dialog.set_comments(Some(&gettext("A media player with a table of contents"))); 165 | dialog.set_copyright(Some(&"© 2017–2020 François Laignel")); 166 | dialog.set_translator_credits(Some(&gettext("translator-credits"))); 167 | dialog.set_license_type(gtk::License::MitX11); 168 | dialog.set_version(Some(env!("CARGO_PKG_VERSION"))); 169 | dialog.set_website(Some(env!("CARGO_PKG_HOMEPAGE"))); 170 | dialog.set_website_label(Some(&gettext("Learn more about media-toc-player"))); 171 | 172 | dialog.connect_response(|dialog, _| dialog.close()); 173 | dialog.show(); 174 | } 175 | 176 | pub fn quit(&mut self) { 177 | self.abort_tracker(); 178 | 179 | if let Some(mut pipeline) = self.pipeline.take() { 180 | let _ = pipeline.stop(); 181 | } 182 | 183 | if let Some(window_delete_id) = self.window_delete_id.take() { 184 | let size = self.window.get_size(); 185 | let mut config = CONFIG.write().unwrap(); 186 | config.ui.width = size.0; 187 | config.ui.height = size.1; 188 | config.save(); 189 | 190 | // Restore default delete handler 191 | glib::signal::signal_handler_disconnect(&self.window, window_delete_id); 192 | } 193 | 194 | self.window.close(); 195 | } 196 | 197 | pub async fn play_pause(&mut self) { 198 | use ControllerState::*; 199 | 200 | match self.state { 201 | Paused => { 202 | self.play_pause_btn.set_icon_name(Some(PAUSE_ICON)); 203 | self.state = Playing; 204 | self.pipeline.as_mut().unwrap().play().await.unwrap(); 205 | 206 | self.spawn_tracker(); 207 | } 208 | Playing => { 209 | self.pipeline.as_mut().unwrap().pause().await.unwrap(); 210 | self.play_pause_btn.set_icon_name(Some(PLAYBACK_ICON)); 211 | self.abort_tracker(); 212 | self.state = Paused; 213 | } 214 | EosPlaying | EosPaused => { 215 | // Restart the stream from the begining 216 | self.play_pause_btn.set_icon_name(Some(PAUSE_ICON)); 217 | self.state = Playing; 218 | 219 | if self 220 | .seek(Timestamp::default(), gst::SeekFlags::ACCURATE) 221 | .await 222 | .is_ok() 223 | { 224 | self.pipeline.as_mut().unwrap().play().await.unwrap(); 225 | self.spawn_tracker(); 226 | } 227 | } 228 | Stopped => self.select_media().await, 229 | PendingSelectMediaDecision => (), 230 | } 231 | } 232 | 233 | pub async fn seek(&mut self, position: Timestamp, flags: gst::SeekFlags) -> Result<(), ()> { 234 | use ControllerState::*; 235 | 236 | match self.state { 237 | Playing | Paused | EosPaused | EosPlaying => { 238 | match self.pipeline.as_mut().unwrap().seek(position, flags).await { 239 | Ok(()) => { 240 | self.info_ctrl.seek(position, self.state); 241 | 242 | match self.state { 243 | EosPlaying => self.state = Playing, 244 | EosPaused => self.state = Paused, 245 | _ => (), 246 | } 247 | } 248 | Err(SeekError::Eos) => { 249 | self.info_ctrl.seek(position, self.state); 250 | self.ui_event.eos(); 251 | } 252 | Err(SeekError::Unrecoverable) => { 253 | self.stop(); 254 | return Err(()); 255 | } 256 | } 257 | } 258 | _ => (), 259 | } 260 | 261 | Ok(()) 262 | } 263 | 264 | pub fn current_ts(&mut self) -> Option { 265 | self.pipeline.as_mut().unwrap().current_ts() 266 | } 267 | 268 | pub fn tick(&mut self) { 269 | if let Some(ts) = self.current_ts() { 270 | self.info_ctrl.tick(ts, self.state); 271 | } 272 | } 273 | 274 | pub async fn select_streams(&mut self, stream_ids: &[Arc]) { 275 | let res = self 276 | .pipeline 277 | .as_mut() 278 | .unwrap() 279 | .select_streams(stream_ids) 280 | .await; 281 | 282 | match res { 283 | Ok(()) => self.streams_selected(), 284 | Err(SelectStreamsError::Unrecoverable) => self.stop(), 285 | Err(err) => panic!("{}", err), 286 | } 287 | } 288 | 289 | pub fn streams_selected(&mut self) { 290 | let info = &self.pipeline.as_ref().unwrap().info; 291 | self.info_ctrl.streams_changed(info); 292 | self.perspective_ctrl.streams_changed(info); 293 | self.video_ctrl.streams_changed(info); 294 | } 295 | 296 | pub fn eos(&mut self) { 297 | self.play_pause_btn.set_icon_name(Some(PLAYBACK_ICON)); 298 | 299 | use ControllerState::*; 300 | match self.state { 301 | Playing => self.state = EosPlaying, 302 | Paused => self.state = EosPaused, 303 | _ => (), 304 | } 305 | 306 | self.abort_tracker(); 307 | } 308 | 309 | fn spawn_tracker(&mut self) { 310 | if self.tracker_abort_handle.is_some() { 311 | return; 312 | } 313 | 314 | let (abortable_tracker, abort_handle) = abortable(self.new_tracker.as_ref().unwrap()()); 315 | spawn(abortable_tracker.map(drop)); 316 | self.tracker_abort_handle = Some(abort_handle); 317 | } 318 | 319 | fn abort_tracker(&mut self) { 320 | if let Some(abort_handle) = self.tracker_abort_handle.take() { 321 | abort_handle.abort(); 322 | } 323 | } 324 | 325 | pub async fn hold(&mut self) { 326 | self.ui_event.set_cursor_waiting(); 327 | self.play_pause_btn.set_icon_name(Some(PLAYBACK_ICON)); 328 | 329 | if let Some(pipeline) = self.pipeline.as_mut() { 330 | pipeline.pause().await.unwrap(); 331 | }; 332 | } 333 | 334 | pub async fn select_media(&mut self) { 335 | self.abort_tracker(); 336 | 337 | if let ControllerState::Playing | ControllerState::EosPlaying = self.state { 338 | self.hold().await; 339 | } 340 | 341 | self.state = ControllerState::PendingSelectMediaDecision; 342 | 343 | self.ui_event.hide_info_bar(); 344 | 345 | if let Some(ref last_path) = CONFIG.read().unwrap().media.last_path { 346 | self.file_dlg.set_current_folder(last_path); 347 | } 348 | self.file_dlg.show(); 349 | } 350 | 351 | pub fn stop(&mut self) { 352 | self.abort_tracker(); 353 | 354 | if let Some(mut pipeline) = self.pipeline.take() { 355 | let _ = pipeline.stop(); 356 | } 357 | 358 | self.state = ControllerState::Stopped; 359 | } 360 | 361 | pub async fn open_media(&mut self, path: PathBuf) { 362 | if let Some(abort_handle) = self.media_msg_abort_handle.take() { 363 | abort_handle.abort(); 364 | } 365 | 366 | self.stop(); 367 | 368 | self.info_ctrl.cleanup(); 369 | self.video_ctrl.cleanup(); 370 | self.streams_ctrl.cleanup(); 371 | self.perspective_ctrl.cleanup(); 372 | self.header_bar.set_subtitle(Some("")); 373 | 374 | CONFIG.write().unwrap().media.last_path = path.parent().map(ToOwned::to_owned); 375 | 376 | match PlaybackPipeline::try_new(path.as_ref(), &self.video_ctrl.video_sink()).await { 377 | Ok(mut pipeline) => { 378 | if !pipeline.missing_plugins.is_empty() { 379 | self.ui_event 380 | .show_info(gettext("Some streams are not usable. {}").replace( 381 | "{}", 382 | &Self::format_missing_plugins(&pipeline.missing_plugins), 383 | )); 384 | } 385 | 386 | self.header_bar 387 | .set_subtitle(Some(pipeline.info.file_name.as_str())); 388 | 389 | self.info_ctrl.new_media(&pipeline); 390 | self.perspective_ctrl.new_media(&pipeline); 391 | self.streams_ctrl.new_media(&pipeline); 392 | self.video_ctrl.new_media(&pipeline); 393 | 394 | let ui_event = self.ui_event.clone(); 395 | let mut media_msg_rx = pipeline.media_msg_rx.take().unwrap(); 396 | let (media_msg_handler, abort_handle) = abortable(async move { 397 | while let Some(msg) = media_msg_rx.next().await { 398 | match msg { 399 | MediaMessage::Eos => ui_event.eos(), 400 | MediaMessage::Error(err) => { 401 | let err = gettext("An unrecoverable error occured. {}") 402 | .replace("{}", &err); 403 | error!("{}", err); 404 | ui_event.show_error(err); 405 | break; 406 | } 407 | } 408 | } 409 | }); 410 | self.media_msg_abort_handle = Some(abort_handle); 411 | spawn(media_msg_handler.map(|_| ())); 412 | 413 | self.pipeline = Some(pipeline); 414 | 415 | self.streams_selected(); 416 | 417 | self.ui_event.reset_cursor(); 418 | self.state = ControllerState::Paused; 419 | } 420 | Err(error) => { 421 | use super::media::playback_pipeline::OpenError; 422 | 423 | self.ui_event.reset_cursor(); 424 | 425 | let error = match error { 426 | OpenError::Generic(error) => error, 427 | OpenError::MissingPlugins(plugins) => Self::format_missing_plugins(&plugins), 428 | OpenError::StateChange => gettext("Failed to switch the media to Paused"), 429 | OpenError::GLSinkError => { 430 | let mut config = CONFIG.write().expect("Failed to get CONFIG as mut"); 431 | config.media.is_gl_disabled = true; 432 | config.save(); 433 | 434 | gettext( 435 | "Video rendering hardware acceleration seems broken and has been disabled.\nPlease restart the application.", 436 | ) 437 | } 438 | }; 439 | 440 | self.ui_event 441 | .show_error(gettext("Error opening file. {}").replace("{}", &error)); 442 | } 443 | }; 444 | } 445 | 446 | fn format_missing_plugins(plugins: &MissingPlugins) -> String { 447 | ngettext( 448 | "Missing plugin:\n{}", 449 | "Missing plugins:\n{}", 450 | plugins.len() as u32, 451 | ) 452 | .replacen("{}", &format!("{}", plugins), 1) 453 | } 454 | 455 | pub fn cancel_select_media(&mut self) { 456 | if self.state == ControllerState::PendingSelectMediaDecision { 457 | self.state = if self.pipeline.is_some() { 458 | ControllerState::Paused 459 | } else { 460 | ControllerState::Stopped 461 | }; 462 | } 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /src/ui/main_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use futures::channel::mpsc as async_mpsc; 2 | use futures::prelude::*; 3 | 4 | use gdk::{Cursor, CursorType, WindowExt}; 5 | 6 | use gettextrs::gettext; 7 | 8 | use gio::prelude::*; 9 | use glib::clone; 10 | use gtk::prelude::*; 11 | 12 | use log::debug; 13 | 14 | use std::{cell::RefCell, rc::Rc, time::Duration}; 15 | 16 | use crate::media::Timestamp; 17 | 18 | use super::{ 19 | info_controller, spawn, ui_event::UIEvent, InfoBarController, InfoDispatcher, MainController, 20 | PerspectiveDispatcher, PlaybackPipeline, StreamsDispatcher, UIController, UIDispatcher, 21 | UIFocusContext, VideoDispatcher, 22 | }; 23 | 24 | const TRACKER_PERIOD: u64 = 40; // 40 ms (25 Hz) 25 | 26 | pub struct MainDispatcher { 27 | app: gtk::Application, 28 | window: gtk::ApplicationWindow, 29 | main_ctrl: Rc>, 30 | info_bar_ctrl: InfoBarController, 31 | saved_context: Option, 32 | focus: UIFocusContext, 33 | } 34 | 35 | impl MainDispatcher { 36 | pub(super) fn setup( 37 | main_ctrl: &mut MainController, 38 | main_ctrl_rc: &Rc>, 39 | app: >k::Application, 40 | window: >k::ApplicationWindow, 41 | builder: >k::Builder, 42 | mut ui_event_receiver: async_mpsc::UnboundedReceiver, 43 | ) { 44 | let mut handler = MainDispatcher { 45 | app: app.clone(), 46 | window: window.clone(), 47 | main_ctrl: Rc::clone(&main_ctrl_rc), 48 | info_bar_ctrl: InfoBarController::new(app, builder, main_ctrl.ui_event()), 49 | saved_context: None, 50 | focus: UIFocusContext::PlaybackPage, 51 | }; 52 | 53 | spawn(async move { 54 | while let Some(event) = ui_event_receiver.next().await { 55 | debug!("handling event {:?}", event); 56 | if handler.handle(event).await.is_err() { 57 | break; 58 | } 59 | } 60 | }); 61 | 62 | let app_menu = gio::Menu::new(); 63 | app.set_app_menu(Some(&app_menu)); 64 | 65 | let app_section = gio::Menu::new(); 66 | app_menu.append_section(None, &app_section); 67 | 68 | // About 69 | let about = gio::SimpleAction::new("about", None); 70 | app.add_action(&about); 71 | about.connect_activate( 72 | clone!(@strong main_ctrl.ui_event as ui_event => move |_, _| { 73 | ui_event.about(); 74 | }), 75 | ); 76 | app.set_accels_for_action("app.about", &["A"]); 77 | app_section.append(Some(&gettext("About")), Some("app.about")); 78 | 79 | // Quit 80 | let quit = gio::SimpleAction::new("quit", None); 81 | app.add_action(&quit); 82 | quit.connect_activate( 83 | clone!(@strong main_ctrl.ui_event as ui_event => move |_, _| { 84 | ui_event.quit(); 85 | }), 86 | ); 87 | app.set_accels_for_action("app.quit", &["Q"]); 88 | app_section.append(Some(&gettext("Quit")), Some("app.quit")); 89 | 90 | main_ctrl.window_delete_id = Some(main_ctrl.window.connect_delete_event( 91 | clone!(@strong main_ctrl.ui_event as ui_event => move |_, _| { 92 | ui_event.quit(); 93 | Inhibit(true) 94 | }), 95 | )); 96 | 97 | let ui_event = main_ctrl.ui_event().clone(); 98 | if gst::init().is_ok() { 99 | PerspectiveDispatcher::setup( 100 | &mut main_ctrl.perspective_ctrl, 101 | main_ctrl_rc, 102 | &app, 103 | &ui_event, 104 | ); 105 | VideoDispatcher::setup(&mut main_ctrl.video_ctrl, main_ctrl_rc, &app, &ui_event); 106 | InfoDispatcher::setup(&mut main_ctrl.info_ctrl, main_ctrl_rc, &app, &ui_event); 107 | StreamsDispatcher::setup(&mut main_ctrl.streams_ctrl, main_ctrl_rc, &app, &ui_event); 108 | 109 | main_ctrl.new_tracker = Some(Box::new(clone!(@weak main_ctrl_rc => 110 | @default-panic, move || { 111 | let main_ctrl_rc = Rc::clone(&main_ctrl_rc); 112 | async move { 113 | loop { 114 | glib::timeout_future(Duration::from_millis(TRACKER_PERIOD)).await; 115 | if let Ok(mut main_ctrl) = main_ctrl_rc.try_borrow_mut() { 116 | main_ctrl.tick(); 117 | } 118 | } 119 | }.boxed_local() 120 | }))); 121 | 122 | let _ = PlaybackPipeline::check_requirements() 123 | .map_err(clone!(@strong ui_event => move |err| ui_event.show_error(err))); 124 | 125 | let main_section = gio::Menu::new(); 126 | app_menu.insert_section(0, None, &main_section); 127 | 128 | // Register Open action 129 | let open = gio::SimpleAction::new("open", None); 130 | app.add_action(&open); 131 | open.connect_activate(clone!(@strong ui_event => move |_, _| ui_event.select_media())); 132 | main_section.append(Some(&gettext("Open media file")), Some("app.open")); 133 | app.set_accels_for_action("app.open", &["O"]); 134 | 135 | main_ctrl.open_btn.set_sensitive(true); 136 | 137 | // Register Play/Pause action 138 | let play_pause = gio::SimpleAction::new("play_pause", None); 139 | app.add_action(&play_pause); 140 | play_pause.connect_activate(clone!(@strong ui_event => move |_, _| { 141 | ui_event.play_pause(); 142 | })); 143 | main_ctrl.play_pause_btn.set_sensitive(true); 144 | 145 | main_ctrl 146 | .display_page 147 | .connect_map(clone!(@strong ui_event => move |_| { 148 | ui_event.switch_to(UIFocusContext::PlaybackPage); 149 | })); 150 | 151 | ui_event.switch_to(UIFocusContext::PlaybackPage); 152 | } else { 153 | // GStreamer initialization failed 154 | let msg = gettext("Failed to initialize GStreamer, the application can't be used."); 155 | ui_event.show_error(msg); 156 | } 157 | } 158 | } 159 | 160 | impl MainDispatcher { 161 | async fn handle(&mut self, event: UIEvent) -> Result<(), ()> { 162 | use UIEvent::*; 163 | 164 | match event { 165 | About => self.main_ctrl.borrow().about(), 166 | CancelSelectMedia => self.main_ctrl.borrow_mut().cancel_select_media(), 167 | ChapterClicked(tree_path) => { 168 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 169 | let seek_ts = main_ctrl 170 | .info_ctrl 171 | .chapter_manager 172 | .chapter_from_path(&tree_path) 173 | .map(|chapter| chapter.start()); 174 | 175 | if let Some(seek_ts) = seek_ts { 176 | let _ = main_ctrl.seek(seek_ts, gst::SeekFlags::ACCURATE).await; 177 | } 178 | } 179 | Eos => self.main_ctrl.borrow_mut().eos(), 180 | HideInfoBar => self.info_bar_ctrl.hide(), 181 | NextChapter => { 182 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 183 | let seek_ts = main_ctrl 184 | .info_ctrl 185 | .chapter_manager 186 | .pick_next() 187 | .map(|next_chapter| next_chapter.start()); 188 | 189 | if let Some(seek_ts) = seek_ts { 190 | let _ = main_ctrl.seek(seek_ts, gst::SeekFlags::ACCURATE).await; 191 | } 192 | } 193 | OpenMedia(path) => self.main_ctrl.borrow_mut().open_media(path).await, 194 | PlayPause => self.main_ctrl.borrow_mut().play_pause().await, 195 | PreviousChapter => { 196 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 197 | let seek_ts = main_ctrl 198 | .current_ts() 199 | .and_then(|cur_ts| main_ctrl.info_ctrl.previous_chapter(cur_ts)); 200 | 201 | let _ = main_ctrl 202 | .seek( 203 | seek_ts.unwrap_or_else(Timestamp::default), 204 | gst::SeekFlags::ACCURATE, 205 | ) 206 | .await; 207 | } 208 | Quit => { 209 | self.main_ctrl.borrow_mut().quit(); 210 | return Err(()); 211 | } 212 | ResetCursor => self.reset_cursor(), 213 | RestoreContext => self.restore_context(), 214 | ShowAll => self.show_all(), 215 | Seek { target, flags } => { 216 | let _ = self.main_ctrl.borrow_mut().seek(target, flags).await; 217 | } 218 | SelectMedia => self.main_ctrl.borrow_mut().select_media().await, 219 | SetCursorWaiting => self.set_cursor_waiting(), 220 | ShowError(msg) => self.info_bar_ctrl.show_error(msg), 221 | ShowInfo(msg) => self.info_bar_ctrl.show_info(msg), 222 | StepBack => { 223 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 224 | if let Some(current_ts) = main_ctrl.current_ts() { 225 | let seek_ts = current_ts.saturating_sub(info_controller::SEEK_STEP); 226 | let _ = main_ctrl.seek(seek_ts, gst::SeekFlags::ACCURATE).await; 227 | } 228 | } 229 | StepForward => { 230 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 231 | if let Some(current_ts) = main_ctrl.current_ts() { 232 | let seek_ts = current_ts + info_controller::SEEK_STEP; 233 | let _ = main_ctrl.seek(seek_ts, gst::SeekFlags::ACCURATE).await; 234 | } 235 | } 236 | StreamClicked(type_) => { 237 | let mut main_ctrl = self.main_ctrl.borrow_mut(); 238 | if let super::StreamClickedStatus::Changed = 239 | main_ctrl.streams_ctrl.stream_clicked(type_) 240 | { 241 | let streams = main_ctrl.streams_ctrl.selected_streams(); 242 | main_ctrl.select_streams(&streams).await; 243 | } 244 | } 245 | SwitchTo(focus_ctx) => self.switch_to(focus_ctx), 246 | TemporarilySwitchTo(focus_ctx) => { 247 | self.save_context(); 248 | self.bind_accels_for(focus_ctx); 249 | } 250 | ToggleChapterList(must_show) => self 251 | .main_ctrl 252 | .borrow() 253 | .info_ctrl 254 | .toggle_chapter_list(must_show), 255 | ToggleRepeat(must_repeat) => { 256 | self.main_ctrl.borrow_mut().info_ctrl.repeat_chapter = must_repeat 257 | } 258 | UpdateFocus => self.update_focus(), 259 | } 260 | 261 | Ok(()) 262 | } 263 | 264 | pub fn show_all(&self) { 265 | self.window.show(); 266 | self.window.activate(); 267 | } 268 | 269 | fn set_cursor_waiting(&self) { 270 | if let Some(gdk_window) = self.window.get_window() { 271 | gdk_window.set_cursor(Some(&Cursor::new_for_display( 272 | &gdk_window.get_display(), 273 | CursorType::Watch, 274 | ))); 275 | } 276 | } 277 | 278 | fn reset_cursor(&self) { 279 | if let Some(gdk_window) = self.window.get_window() { 280 | gdk_window.set_cursor(None); 281 | } 282 | } 283 | 284 | fn bind_accels_for(&self, ctx: UIFocusContext) { 285 | match ctx { 286 | UIFocusContext::PlaybackPage => { 287 | self.app 288 | .set_accels_for_action("app.play_pause", &["space", "AudioPlay"]); 289 | self.app 290 | .set_accels_for_action("app.next_chapter", &["Down", "AudioNext"]); 291 | self.app 292 | .set_accels_for_action("app.previous_chapter", &["Up", "AudioPrev"]); 293 | self.app.set_accels_for_action("app.close_info_bar", &[]); 294 | } 295 | UIFocusContext::StreamsPage => { 296 | self.app 297 | .set_accels_for_action("app.play_pause", &["space", "AudioPlay"]); 298 | self.app 299 | .set_accels_for_action("app.next_chapter", &["AudioNext"]); 300 | self.app 301 | .set_accels_for_action("app.previous_chapter", &["AudioPrev"]); 302 | self.app.set_accels_for_action("app.close_info_bar", &[]); 303 | } 304 | UIFocusContext::InfoBar => { 305 | self.app 306 | .set_accels_for_action("app.play_pause", &["AudioPlay"]); 307 | self.app.set_accels_for_action("app.next_chapter", &[]); 308 | self.app.set_accels_for_action("app.previous_chapter", &[]); 309 | self.app 310 | .set_accels_for_action("app.close_info_bar", &["Escape"]); 311 | } 312 | } 313 | 314 | PerspectiveDispatcher::bind_accels_for(ctx, &self.app); 315 | VideoDispatcher::bind_accels_for(ctx, &self.app); 316 | InfoDispatcher::bind_accels_for(ctx, &self.app); 317 | StreamsDispatcher::bind_accels_for(ctx, &self.app); 318 | } 319 | 320 | fn update_focus(&self) { 321 | let main_ctrl = self.main_ctrl.borrow(); 322 | match self.focus { 323 | UIFocusContext::PlaybackPage => main_ctrl.info_ctrl.grab_focus(), 324 | UIFocusContext::StreamsPage => main_ctrl.streams_ctrl.grab_focus(), 325 | _ => (), 326 | } 327 | } 328 | 329 | fn switch_to(&mut self, ctx: UIFocusContext) { 330 | self.focus = ctx; 331 | self.bind_accels_for(ctx); 332 | self.update_focus(); 333 | } 334 | 335 | fn save_context(&mut self) { 336 | self.saved_context = Some(self.focus); 337 | } 338 | 339 | fn restore_context(&mut self) { 340 | if let Some(focus_ctx) = self.saved_context.take() { 341 | self.switch_to(focus_ctx); 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod chapter_tree_manager; 2 | use self::chapter_tree_manager::{ChapterTreeManager, PositionStatus}; 3 | 4 | mod image; 5 | use self::image::Image; 6 | 7 | mod info_bar_controller; 8 | use self::info_bar_controller::InfoBarController; 9 | 10 | mod info_controller; 11 | use self::info_controller::InfoController; 12 | mod info_dispatcher; 13 | use self::info_dispatcher::InfoDispatcher; 14 | 15 | pub mod main_controller; 16 | pub use self::main_controller::{ControllerState, MainController}; 17 | mod main_dispatcher; 18 | pub use self::main_dispatcher::MainDispatcher; 19 | 20 | mod perspective_controller; 21 | use self::perspective_controller::PerspectiveController; 22 | mod perspective_dispatcher; 23 | use self::perspective_dispatcher::PerspectiveDispatcher; 24 | 25 | mod streams_controller; 26 | use self::streams_controller::{StreamClickedStatus, StreamsController}; 27 | mod streams_dispatcher; 28 | use self::streams_dispatcher::StreamsDispatcher; 29 | 30 | mod ui_event; 31 | use self::ui_event::{UIEventSender, UIFocusContext}; 32 | 33 | mod video_controller; 34 | use self::video_controller::VideoController; 35 | mod video_dispatcher; 36 | use self::video_dispatcher::VideoDispatcher; 37 | 38 | use futures::prelude::*; 39 | use gio::prelude::*; 40 | use log::warn; 41 | 42 | use std::{cell::RefCell, rc::Rc}; 43 | 44 | use crate::{ 45 | application::{CommandLineArguments, APP_ID}, 46 | media::{self, PlaybackPipeline}, 47 | metadata, 48 | }; 49 | 50 | fn spawn + 'static>(future: Fut) { 51 | glib::MainContext::ref_thread_default().spawn_local(future); 52 | } 53 | 54 | fn register_resource(resource: &[u8]) { 55 | let gbytes = glib::Bytes::from(resource); 56 | gio::Resource::from_data(&gbytes) 57 | .map(|resource| { 58 | gio::resources_register(&resource); 59 | }) 60 | .unwrap_or_else(|err| { 61 | warn!("unable to load resources: {:?}", err); 62 | }); 63 | } 64 | 65 | pub fn run(args: CommandLineArguments) { 66 | register_resource(include_bytes!("../../target/resources/ui.gresource")); 67 | 68 | let gtk_app = gtk::Application::new(Some(&APP_ID), gio::ApplicationFlags::empty()) 69 | .expect("Failed to initialize GtkApplication"); 70 | 71 | gtk_app.connect_activate(move |gtk_app| MainController::setup(gtk_app, &args)); 72 | gtk_app.run(&[]); 73 | } 74 | 75 | pub trait UIController { 76 | fn new_media(&mut self, _pipeline: &PlaybackPipeline) {} 77 | fn cleanup(&mut self); 78 | fn streams_changed(&mut self, _info: &metadata::MediaInfo) {} 79 | fn grab_focus(&self) {} 80 | } 81 | 82 | pub trait UIDispatcher { 83 | type Controller: UIController; 84 | 85 | fn setup( 86 | ctrl: &mut Self::Controller, 87 | main_ctrl_rc: &Rc>, 88 | app: >k::Application, 89 | ui_event: &UIEventSender, 90 | ); 91 | 92 | // bind context specific accels 93 | fn bind_accels_for(_ctx: UIFocusContext, _app: >k::Application) {} 94 | } 95 | -------------------------------------------------------------------------------- /src/ui/perspective_controller.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | 3 | use crate::media::PlaybackPipeline; 4 | 5 | use super::UIController; 6 | 7 | pub struct PerspectiveController { 8 | pub(super) menu_btn: gtk::MenuButton, 9 | pub(super) popover: gtk::PopoverMenu, 10 | pub(super) stack: gtk::Stack, 11 | } 12 | 13 | impl PerspectiveController { 14 | pub fn new(builder: >k::Builder) -> Self { 15 | let mut ctrl = PerspectiveController { 16 | menu_btn: builder.get_object("perspective-menu-btn").unwrap(), 17 | popover: builder.get_object("perspective-popovermenu").unwrap(), 18 | stack: builder.get_object("perspective-stack").unwrap(), 19 | }; 20 | 21 | ctrl.cleanup(); 22 | 23 | ctrl 24 | } 25 | } 26 | 27 | impl UIController for PerspectiveController { 28 | fn new_media(&mut self, _pipeline: &PlaybackPipeline) { 29 | self.menu_btn.set_sensitive(true); 30 | } 31 | 32 | fn cleanup(&mut self) { 33 | self.menu_btn.set_sensitive(false); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/perspective_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use gio::prelude::*; 2 | use glib::Cast; 3 | use gtk::prelude::*; 4 | 5 | use std::{cell::RefCell, rc::Rc}; 6 | 7 | use super::{MainController, PerspectiveController, UIDispatcher, UIEventSender}; 8 | 9 | macro_rules! gtk_downcast( 10 | ($source:expr, $target_type:ty, $item_name:expr) => { 11 | $source.clone() 12 | .downcast::<$target_type>() 13 | .expect(&format!(concat!("PerspectiveController ", 14 | "unexpected type for perspective item {:?}", 15 | ), 16 | $item_name, 17 | )) 18 | }; 19 | ($source:expr, $item_index:expr, $target_type:ty, $item_name:expr) => { 20 | $source.get_children() 21 | .get($item_index) 22 | .expect(&format!("PerspectiveController no child at index {} for perspective item {:?}", 23 | $item_index, 24 | $item_name, 25 | )) 26 | .clone() 27 | .downcast::<$target_type>() 28 | .expect(&format!(concat!("PerspectiveController ", 29 | "unexpected type for perspective item {:?}", 30 | ), 31 | $item_name, 32 | )) 33 | }; 34 | ); 35 | 36 | pub struct PerspectiveDispatcher; 37 | impl UIDispatcher for PerspectiveDispatcher { 38 | type Controller = PerspectiveController; 39 | 40 | fn setup( 41 | perspective_ctrl: &mut PerspectiveController, 42 | _main_ctrl_rc: &Rc>, 43 | app: >k::Application, 44 | _ui_event: &UIEventSender, 45 | ) { 46 | let menu_btn_box = gtk_downcast!( 47 | perspective_ctrl 48 | .menu_btn 49 | .get_child() 50 | .expect("PerspectiveController no box for menu button"), 51 | gtk::Box, 52 | "menu button" 53 | ); 54 | let menu_btn_image = gtk_downcast!(menu_btn_box, 0, gtk::Image, "menu button"); 55 | 56 | let popover_box = gtk_downcast!(perspective_ctrl.popover, 0, gtk::Box, "popover"); 57 | 58 | let stack_children = perspective_ctrl.stack.get_children(); 59 | for (index, perspective_box_child) in popover_box.get_children().into_iter().enumerate() { 60 | let stack_child = stack_children.get(index).unwrap_or_else(|| { 61 | panic!("PerspectiveController no stack child for index {:?}", index) 62 | }); 63 | 64 | let button = gtk_downcast!(perspective_box_child, gtk::Button, "popover box"); 65 | let button_name = button.get_widget_name(); 66 | let button_box = gtk_downcast!( 67 | button.get_child().unwrap_or_else(|| panic!( 68 | "PerspectiveController no box for button {:?}", 69 | button_name 70 | )), 71 | gtk::Box, 72 | button_name 73 | ); 74 | 75 | let perspective_icon_name = gtk_downcast!(button_box, 0, gtk::Image, button_name) 76 | .get_property_icon_name() 77 | .unwrap_or_else(|| { 78 | panic!( 79 | "PerspectiveController no icon name for button {:?}", 80 | button_name, 81 | ) 82 | }); 83 | 84 | let stack_child_name = perspective_ctrl 85 | .stack 86 | .get_child_name(stack_child) 87 | .unwrap_or_else(|| { 88 | panic!( 89 | "PerspectiveController no name for stack page matching {:?}", 90 | button_name, 91 | ) 92 | }) 93 | .to_owned(); 94 | 95 | if index == 0 { 96 | // set the default perspective 97 | menu_btn_image.set_property_icon_name(Some(perspective_icon_name.as_str())); 98 | perspective_ctrl 99 | .stack 100 | .set_visible_child_name(&stack_child_name); 101 | } 102 | 103 | button.set_sensitive(true); 104 | 105 | let menu_btn_image = menu_btn_image.clone(); 106 | let stack_clone = perspective_ctrl.stack.clone(); 107 | let popover_clone = perspective_ctrl.popover.clone(); 108 | let event = move || { 109 | menu_btn_image.set_property_icon_name(Some(perspective_icon_name.as_str())); 110 | stack_clone.set_visible_child_name(&stack_child_name); 111 | // popdown is available from GTK 3.22 112 | // current package used on travis is GTK 3.18 113 | popover_clone.hide(); 114 | }; 115 | 116 | match button.get_action_name() { 117 | Some(action_name) => { 118 | let accel_key = 119 | gtk_downcast!(button_box, 2, gtk::Label, button_name).get_text(); 120 | let action_splits: Vec<&str> = action_name.splitn(2, '.').collect(); 121 | if action_splits.len() != 2 { 122 | panic!( 123 | "PerspectiveController unexpected action name for button {:?}", 124 | button_name, 125 | ); 126 | } 127 | 128 | let action = gio::SimpleAction::new(action_splits[1], None); 129 | app.add_action(&action); 130 | action.connect_activate(move |_, _| event()); 131 | app.set_accels_for_action(&action_name, &[&accel_key]); 132 | } 133 | None => { 134 | button.connect_clicked(move |_| event()); 135 | } 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ui/streams_controller.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | 3 | use gtk::prelude::*; 4 | 5 | use std::sync::Arc; 6 | 7 | use crate::{media::PlaybackPipeline, metadata}; 8 | 9 | use super::{spawn, UIController}; 10 | 11 | const ALIGN_LEFT: f32 = 0f32; 12 | const ALIGN_CENTER: f32 = 0.5f32; 13 | const ALIGN_RIGHT: f32 = 1f32; 14 | 15 | const STREAM_ID_COL: u32 = 0; 16 | const STREAM_ID_DISPLAY_COL: u32 = 1; 17 | 18 | const LANGUAGE_COL: u32 = 2; 19 | const CODEC_COL: u32 = 3; 20 | const COMMENT_COL: u32 = 4; 21 | 22 | pub enum StreamClickedStatus { 23 | Changed, 24 | Unchanged, 25 | } 26 | 27 | pub(super) trait UIStreamImpl { 28 | const TYPE: gst::StreamType; 29 | 30 | fn new_media(store: >k::ListStore, iter: >k::TreeIter, caps_struct: &gst::StructureRef); 31 | fn init_treeview(treeview: >k::TreeView, store: >k::ListStore); 32 | 33 | fn add_text_column( 34 | treeview: >k::TreeView, 35 | title: &str, 36 | alignment: f32, 37 | col_id: u32, 38 | width: Option, 39 | ) { 40 | let col = gtk::TreeViewColumn::new(); 41 | col.set_title(title); 42 | 43 | let renderer = gtk::CellRendererText::new(); 44 | renderer.set_alignment(alignment, ALIGN_CENTER); 45 | col.pack_start(&renderer, true); 46 | col.add_attribute(&renderer, "text", col_id as i32); 47 | 48 | if let Some(width) = width { 49 | renderer.set_fixed_size(width, -1); 50 | } 51 | 52 | treeview.append_column(&col); 53 | } 54 | } 55 | 56 | pub(super) struct UIStream { 57 | pub(super) treeview: gtk::TreeView, 58 | store: gtk::ListStore, 59 | selected: Option>, 60 | phantom: std::marker::PhantomData, 61 | } 62 | 63 | impl UIStream { 64 | fn new(treeview: gtk::TreeView, store: gtk::ListStore) -> Self { 65 | UIStream { 66 | treeview, 67 | store, 68 | selected: None, 69 | phantom: std::marker::PhantomData, 70 | } 71 | } 72 | 73 | fn init_treeview(&self) { 74 | Impl::init_treeview(&self.treeview, &self.store); 75 | } 76 | 77 | fn cleanup(&mut self) { 78 | self.selected = None; 79 | self.treeview 80 | .set_cursor(>k::TreePath::new(), None::<>k::TreeViewColumn>, false); 81 | self.store.clear(); 82 | } 83 | 84 | fn new_media(&mut self, streams: &metadata::Streams) { 85 | let sorted_collection = streams.collection(Impl::TYPE).sorted(); 86 | for stream in sorted_collection { 87 | let iter = self.add_stream(stream); 88 | let caps_structure = stream.caps.get_structure(0).unwrap(); 89 | Impl::new_media(&self.store, &iter, &caps_structure); 90 | } 91 | 92 | self.selected = self.store.get_iter_first().map(|ref iter| { 93 | self.treeview.get_selection().select_iter(iter); 94 | self.store 95 | .get_value(iter, STREAM_ID_COL as i32) 96 | .get::() 97 | .unwrap() 98 | .unwrap() 99 | .into() 100 | }); 101 | } 102 | 103 | fn add_stream(&self, stream: &metadata::Stream) -> gtk::TreeIter { 104 | let id_parts: Vec<&str> = stream.id.split('/').collect(); 105 | let stream_id_display = if id_parts.len() == 2 { 106 | id_parts[1].to_owned() 107 | } else { 108 | gettext("unknown") 109 | }; 110 | 111 | let iter = self.store.insert_with_values( 112 | None, 113 | &[STREAM_ID_COL, STREAM_ID_DISPLAY_COL], 114 | &[&stream.id.as_ref(), &stream_id_display], 115 | ); 116 | 117 | let lang = stream 118 | .tags 119 | .get_index::(0) 120 | .or_else(|| stream.tags.get_index::(0)) 121 | .and_then(|value| value.get()) 122 | .unwrap_or("-"); 123 | self.store 124 | .set_value(&iter, LANGUAGE_COL, &glib::Value::from(lang)); 125 | 126 | if let Some(comment) = stream 127 | .tags 128 | .get_index::(0) 129 | .and_then(|value| value.get()) 130 | { 131 | self.store 132 | .set_value(&iter, COMMENT_COL, &glib::Value::from(comment)); 133 | } 134 | 135 | self.store.set_value( 136 | &iter, 137 | CODEC_COL, 138 | &glib::Value::from(&stream.codec_printable), 139 | ); 140 | 141 | iter 142 | } 143 | 144 | fn stream_clicked(&mut self) -> StreamClickedStatus { 145 | if let (Some(cursor_path), _) = self.treeview.get_cursor() { 146 | if let Some(iter) = self.store.get_iter(&cursor_path) { 147 | let stream = self 148 | .store 149 | .get_value(&iter, STREAM_ID_COL as i32) 150 | .get::() 151 | .unwrap() 152 | .unwrap() 153 | .into(); 154 | let stream_to_select = match &self.selected { 155 | Some(stream_id) => { 156 | if stream_id != &stream { 157 | // Stream has changed 158 | Some(stream) 159 | } else { 160 | None 161 | } 162 | } 163 | None => Some(stream), 164 | }; 165 | 166 | if let Some(new_stream) = stream_to_select { 167 | self.selected = Some(new_stream); 168 | return StreamClickedStatus::Changed; 169 | } 170 | } 171 | } 172 | 173 | StreamClickedStatus::Unchanged 174 | } 175 | } 176 | 177 | pub(super) struct UIStreamVideoImpl; 178 | impl UIStreamVideoImpl { 179 | const VIDEO_WIDTH_COL: u32 = 5; 180 | const VIDEO_HEIGHT_COL: u32 = 6; 181 | } 182 | 183 | impl UIStreamImpl for UIStreamVideoImpl { 184 | const TYPE: gst::StreamType = gst::StreamType::VIDEO; 185 | 186 | fn new_media(store: >k::ListStore, iter: >k::TreeIter, caps_struct: &gst::StructureRef) { 187 | if let Ok(Some(width)) = caps_struct.get::("width") { 188 | store.set_value(iter, Self::VIDEO_WIDTH_COL, &glib::Value::from(&width)); 189 | } 190 | if let Ok(Some(height)) = caps_struct.get::("height") { 191 | store.set_value(iter, Self::VIDEO_HEIGHT_COL, &glib::Value::from(&height)); 192 | } 193 | } 194 | 195 | fn init_treeview(treeview: >k::TreeView, store: >k::ListStore) { 196 | treeview.set_model(Some(store)); 197 | 198 | // Video 199 | Self::add_text_column( 200 | treeview, 201 | &gettext("Stream id"), 202 | ALIGN_LEFT, 203 | STREAM_ID_DISPLAY_COL, 204 | Some(200), 205 | ); 206 | Self::add_text_column( 207 | treeview, 208 | &gettext("Language"), 209 | ALIGN_CENTER, 210 | LANGUAGE_COL, 211 | None, 212 | ); 213 | Self::add_text_column(treeview, &gettext("Codec"), ALIGN_LEFT, CODEC_COL, None); 214 | Self::add_text_column( 215 | treeview, 216 | &gettext("Width"), 217 | ALIGN_RIGHT, 218 | Self::VIDEO_WIDTH_COL, 219 | None, 220 | ); 221 | Self::add_text_column( 222 | treeview, 223 | &gettext("Height"), 224 | ALIGN_RIGHT, 225 | Self::VIDEO_HEIGHT_COL, 226 | None, 227 | ); 228 | Self::add_text_column(treeview, &gettext("Comment"), ALIGN_LEFT, COMMENT_COL, None); 229 | } 230 | } 231 | 232 | pub(super) struct UIStreamAudioImpl; 233 | impl UIStreamAudioImpl { 234 | const AUDIO_RATE_COL: u32 = 5; 235 | const AUDIO_CHANNELS_COL: u32 = 6; 236 | } 237 | 238 | impl UIStreamImpl for UIStreamAudioImpl { 239 | const TYPE: gst::StreamType = gst::StreamType::AUDIO; 240 | 241 | fn new_media(store: >k::ListStore, iter: >k::TreeIter, caps_struct: &gst::StructureRef) { 242 | if let Ok(Some(rate)) = caps_struct.get::("rate") { 243 | store.set_value(&iter, Self::AUDIO_RATE_COL, &glib::Value::from(&rate)); 244 | } 245 | if let Ok(Some(channels)) = caps_struct.get::("channels") { 246 | store.set_value( 247 | &iter, 248 | Self::AUDIO_CHANNELS_COL, 249 | &glib::Value::from(&channels), 250 | ); 251 | } 252 | } 253 | 254 | fn init_treeview(treeview: >k::TreeView, store: >k::ListStore) { 255 | treeview.set_model(Some(store)); 256 | 257 | Self::add_text_column( 258 | treeview, 259 | &gettext("Stream id"), 260 | ALIGN_LEFT, 261 | STREAM_ID_DISPLAY_COL, 262 | Some(200), 263 | ); 264 | Self::add_text_column( 265 | treeview, 266 | &gettext("Language"), 267 | ALIGN_CENTER, 268 | LANGUAGE_COL, 269 | None, 270 | ); 271 | Self::add_text_column(treeview, &gettext("Codec"), ALIGN_LEFT, CODEC_COL, None); 272 | Self::add_text_column( 273 | treeview, 274 | &gettext("Rate"), 275 | ALIGN_RIGHT, 276 | Self::AUDIO_RATE_COL, 277 | None, 278 | ); 279 | Self::add_text_column( 280 | treeview, 281 | &gettext("Channels"), 282 | ALIGN_RIGHT, 283 | Self::AUDIO_CHANNELS_COL, 284 | None, 285 | ); 286 | Self::add_text_column(treeview, &gettext("Comment"), ALIGN_LEFT, COMMENT_COL, None); 287 | } 288 | } 289 | 290 | pub(super) struct UIStreamTextImpl; 291 | impl UIStreamTextImpl { 292 | const TEXT_FORMAT_COL: u32 = 5; 293 | } 294 | 295 | impl UIStreamImpl for UIStreamTextImpl { 296 | const TYPE: gst::StreamType = gst::StreamType::TEXT; 297 | 298 | fn new_media(store: >k::ListStore, iter: >k::TreeIter, caps_struct: &gst::StructureRef) { 299 | if let Ok(Some(format)) = caps_struct.get::<&str>("format") { 300 | store.set_value(&iter, Self::TEXT_FORMAT_COL, &glib::Value::from(&format)); 301 | } 302 | } 303 | 304 | fn init_treeview(treeview: >k::TreeView, store: >k::ListStore) { 305 | treeview.set_model(Some(store)); 306 | 307 | Self::add_text_column( 308 | treeview, 309 | &gettext("Stream id"), 310 | ALIGN_LEFT, 311 | STREAM_ID_DISPLAY_COL, 312 | Some(200), 313 | ); 314 | Self::add_text_column( 315 | treeview, 316 | &gettext("Language"), 317 | ALIGN_CENTER, 318 | LANGUAGE_COL, 319 | None, 320 | ); 321 | Self::add_text_column(treeview, &gettext("Codec"), ALIGN_LEFT, CODEC_COL, None); 322 | Self::add_text_column( 323 | treeview, 324 | &gettext("Format"), 325 | ALIGN_LEFT, 326 | Self::TEXT_FORMAT_COL, 327 | None, 328 | ); 329 | Self::add_text_column(treeview, &gettext("Comment"), ALIGN_LEFT, COMMENT_COL, None); 330 | } 331 | } 332 | 333 | pub struct StreamsController { 334 | pub(super) page: gtk::Grid, 335 | 336 | pub(super) video: UIStream, 337 | pub(super) audio: UIStream, 338 | pub(super) text: UIStream, 339 | } 340 | 341 | impl UIController for StreamsController { 342 | fn new_media(&mut self, pipeline: &PlaybackPipeline) { 343 | self.video.new_media(&pipeline.info.streams); 344 | self.audio.new_media(&pipeline.info.streams); 345 | self.text.new_media(&pipeline.info.streams); 346 | } 347 | 348 | fn cleanup(&mut self) { 349 | self.video.cleanup(); 350 | self.audio.cleanup(); 351 | self.text.cleanup(); 352 | } 353 | 354 | fn grab_focus(&self) { 355 | // grab focus asynchronoulsy because it triggers the `cursor_changed` signal 356 | // which needs to check if the stream has changed 357 | let audio_treeview = self.audio.treeview.clone(); 358 | spawn(async move { 359 | audio_treeview.grab_focus(); 360 | }); 361 | } 362 | } 363 | 364 | impl StreamsController { 365 | pub fn new(builder: >k::Builder) -> Self { 366 | let mut ctrl = StreamsController { 367 | page: builder.get_object("streams-grid").unwrap(), 368 | 369 | video: UIStream::new( 370 | builder.get_object("video_streams-treeview").unwrap(), 371 | builder.get_object("video_streams-liststore").unwrap(), 372 | ), 373 | 374 | audio: UIStream::new( 375 | builder.get_object("audio_streams-treeview").unwrap(), 376 | builder.get_object("audio_streams-liststore").unwrap(), 377 | ), 378 | 379 | text: UIStream::new( 380 | builder.get_object("text_streams-treeview").unwrap(), 381 | builder.get_object("text_streams-liststore").unwrap(), 382 | ), 383 | }; 384 | 385 | ctrl.cleanup(); 386 | 387 | ctrl.video.init_treeview(); 388 | ctrl.audio.init_treeview(); 389 | ctrl.text.init_treeview(); 390 | 391 | ctrl 392 | } 393 | 394 | pub(super) fn stream_clicked(&mut self, type_: gst::StreamType) -> StreamClickedStatus { 395 | match type_ { 396 | gst::StreamType::VIDEO => self.video.stream_clicked(), 397 | gst::StreamType::AUDIO => self.audio.stream_clicked(), 398 | gst::StreamType::TEXT => self.text.stream_clicked(), 399 | other => unimplemented!("{:?}", other), 400 | } 401 | } 402 | 403 | pub fn selected_streams(&self) -> Vec> { 404 | let mut streams: Vec> = Vec::new(); 405 | if let Some(stream) = self.video.selected.as_ref() { 406 | streams.push(Arc::clone(stream)); 407 | } 408 | if let Some(stream) = self.audio.selected.as_ref() { 409 | streams.push(Arc::clone(stream)); 410 | } 411 | if let Some(stream) = self.text.selected.as_ref() { 412 | streams.push(Arc::clone(stream)); 413 | } 414 | 415 | streams 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/ui/streams_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use glib::clone; 2 | use gtk::prelude::*; 3 | 4 | use std::{cell::RefCell, rc::Rc}; 5 | 6 | use super::{MainController, StreamsController, UIDispatcher, UIEventSender, UIFocusContext}; 7 | 8 | pub struct StreamsDispatcher; 9 | impl UIDispatcher for StreamsDispatcher { 10 | type Controller = StreamsController; 11 | 12 | fn setup( 13 | streams_ctrl: &mut StreamsController, 14 | _main_ctrl_rc: &Rc>, 15 | _app: >k::Application, 16 | ui_event: &UIEventSender, 17 | ) { 18 | streams_ctrl.video.treeview.connect_cursor_changed( 19 | clone!(@strong ui_event => move |_| ui_event.stream_clicked(gst::StreamType::VIDEO)), 20 | ); 21 | 22 | streams_ctrl.audio.treeview.connect_cursor_changed( 23 | clone!(@strong ui_event => move |_| ui_event.stream_clicked(gst::StreamType::AUDIO)), 24 | ); 25 | 26 | streams_ctrl.text.treeview.connect_cursor_changed( 27 | clone!(@strong ui_event => move |_| ui_event.stream_clicked(gst::StreamType::TEXT)), 28 | ); 29 | 30 | streams_ctrl 31 | .page 32 | .connect_map(clone!(@strong ui_event => move |_| { 33 | ui_event.switch_to(UIFocusContext::StreamsPage); 34 | })); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/ui_event.rs: -------------------------------------------------------------------------------- 1 | use futures::channel::mpsc as async_mpsc; 2 | 3 | use std::{borrow::Cow, cell::RefCell, path::PathBuf}; 4 | 5 | use crate::media::Timestamp; 6 | 7 | #[derive(Clone, Copy, Debug)] 8 | pub enum UIFocusContext { 9 | InfoBar, 10 | PlaybackPage, 11 | StreamsPage, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub enum UIEvent { 16 | About, 17 | CancelSelectMedia, 18 | ChapterClicked(gtk::TreePath), 19 | Eos, 20 | HideInfoBar, 21 | NextChapter, 22 | OpenMedia(PathBuf), 23 | PlayPause, 24 | PreviousChapter, 25 | Quit, 26 | ResetCursor, 27 | RestoreContext, 28 | Seek { 29 | target: Timestamp, 30 | flags: gst::SeekFlags, 31 | }, 32 | SelectMedia, 33 | ShowAll, 34 | SetCursorWaiting, 35 | ShowError(Cow<'static, str>), 36 | ShowInfo(Cow<'static, str>), 37 | StepBack, 38 | StepForward, 39 | StreamClicked(gst::StreamType), 40 | SwitchTo(UIFocusContext), 41 | TemporarilySwitchTo(UIFocusContext), 42 | ToggleChapterList(bool), 43 | ToggleRepeat(bool), 44 | UpdateFocus, 45 | } 46 | 47 | #[derive(Clone)] 48 | pub struct UIEventSender(RefCell>); 49 | 50 | #[allow(unused_must_use)] 51 | impl UIEventSender { 52 | fn send(&self, event: UIEvent) { 53 | let _ = self.0.borrow_mut().unbounded_send(event); 54 | } 55 | 56 | pub fn about(&self) { 57 | self.send(UIEvent::About); 58 | } 59 | 60 | pub fn cancel_select_media(&self) { 61 | self.send(UIEvent::CancelSelectMedia); 62 | } 63 | 64 | pub fn chapter_clicked(&self, tree_path: gtk::TreePath) { 65 | self.send(UIEvent::ChapterClicked(tree_path)); 66 | } 67 | 68 | pub fn eos(&self) { 69 | self.send(UIEvent::Eos); 70 | } 71 | 72 | pub fn hide_info_bar(&self) { 73 | self.send(UIEvent::HideInfoBar); 74 | } 75 | 76 | pub fn next_chapter(&self) { 77 | self.send(UIEvent::NextChapter); 78 | } 79 | 80 | pub fn open_media(&self, path: PathBuf) { 81 | self.set_cursor_waiting(); 82 | self.send(UIEvent::OpenMedia(path)); 83 | } 84 | 85 | pub fn play_pause(&self) { 86 | self.send(UIEvent::PlayPause); 87 | } 88 | 89 | pub fn previous_chapter(&self) { 90 | self.send(UIEvent::PreviousChapter); 91 | } 92 | 93 | pub fn quit(&self) { 94 | self.send(UIEvent::Quit); 95 | } 96 | 97 | pub fn reset_cursor(&self) { 98 | self.send(UIEvent::ResetCursor); 99 | } 100 | 101 | pub fn restore_context(&self) { 102 | self.send(UIEvent::RestoreContext); 103 | } 104 | 105 | pub fn select_media(&self) { 106 | self.send(UIEvent::SelectMedia); 107 | } 108 | 109 | pub fn seek(&self, target: Timestamp, flags: gst::SeekFlags) { 110 | self.send(UIEvent::Seek { target, flags }); 111 | } 112 | 113 | pub fn set_cursor_waiting(&self) { 114 | self.send(UIEvent::SetCursorWaiting); 115 | } 116 | 117 | pub fn show_all(&self) { 118 | self.send(UIEvent::ShowAll); 119 | } 120 | 121 | pub fn show_error(&self, msg: Msg) 122 | where 123 | Msg: Into>, 124 | { 125 | self.send(UIEvent::ShowError(msg.into())); 126 | } 127 | 128 | pub fn show_info(&self, msg: Msg) 129 | where 130 | Msg: Into>, 131 | { 132 | self.send(UIEvent::ShowInfo(msg.into())); 133 | } 134 | 135 | pub fn step_back(&self) { 136 | self.send(UIEvent::StepBack); 137 | } 138 | 139 | pub fn step_forward(&self) { 140 | self.send(UIEvent::StepForward); 141 | } 142 | 143 | pub fn stream_clicked(&self, type_: gst::StreamType) { 144 | self.send(UIEvent::StreamClicked(type_)); 145 | } 146 | 147 | pub fn switch_to(&self, ctx: UIFocusContext) { 148 | self.send(UIEvent::SwitchTo(ctx)); 149 | } 150 | 151 | // Call `restore_context` to retrieve initial state 152 | pub fn temporarily_switch_to(&self, ctx: UIFocusContext) { 153 | self.send(UIEvent::TemporarilySwitchTo(ctx)); 154 | } 155 | 156 | pub fn toggle_chapter_list(&self, must_show: bool) { 157 | self.send(UIEvent::ToggleChapterList(must_show)); 158 | } 159 | 160 | pub fn toggle_repeat(&self, must_repeat: bool) { 161 | self.send(UIEvent::ToggleRepeat(must_repeat)); 162 | } 163 | 164 | pub fn update_focus(&self) { 165 | self.send(UIEvent::UpdateFocus); 166 | } 167 | } 168 | 169 | pub fn new_pair() -> (UIEventSender, async_mpsc::UnboundedReceiver) { 170 | let (sender, receiver) = async_mpsc::unbounded(); 171 | let sender = UIEventSender(RefCell::new(sender)); 172 | 173 | (sender, receiver) 174 | } 175 | -------------------------------------------------------------------------------- /src/ui/video_controller.rs: -------------------------------------------------------------------------------- 1 | use glib::{prelude::*, signal::SignalHandlerId}; 2 | use gtk::prelude::*; 3 | use log::debug; 4 | 5 | use crate::{ 6 | application::{CommandLineArguments, CONFIG}, 7 | metadata::MediaInfo, 8 | }; 9 | 10 | use super::UIController; 11 | 12 | pub struct VideoOutput { 13 | sink: gst::Element, 14 | pub(super) widget: gtk::Widget, 15 | } 16 | 17 | pub struct VideoController { 18 | pub(super) video_output: Option, 19 | pub(super) container: gtk::Box, 20 | cleaner_id: Option, 21 | } 22 | 23 | impl UIController for VideoController { 24 | fn cleanup(&mut self) { 25 | if let Some(video_widget) = self.video_widget() { 26 | if self.cleaner_id.is_none() { 27 | self.cleaner_id = Some(video_widget.connect_draw(|widget, cr| { 28 | let allocation = widget.get_allocation(); 29 | cr.set_source_rgb(0f64, 0f64, 0f64); 30 | cr.rectangle( 31 | 0f64, 32 | 0f64, 33 | f64::from(allocation.width), 34 | f64::from(allocation.height), 35 | ); 36 | cr.fill(); 37 | 38 | Inhibit(true) 39 | })); 40 | video_widget.queue_draw(); 41 | } 42 | } 43 | } 44 | 45 | fn streams_changed(&mut self, info: &MediaInfo) { 46 | if let Some(video_output) = self.video_output.as_ref() { 47 | if let Some(cleaner_id) = self.cleaner_id.take() { 48 | self.container.get_children()[0].disconnect(cleaner_id); 49 | } 50 | 51 | if info.streams.is_video_selected() { 52 | debug!("streams_changed video selected"); 53 | video_output.widget.show(); 54 | } else { 55 | debug!("streams_changed video not selected"); 56 | video_output.widget.hide(); 57 | } 58 | } 59 | } 60 | } 61 | 62 | impl VideoController { 63 | pub fn new(builder: >k::Builder, args: &CommandLineArguments) -> Self { 64 | let container: gtk::Box = builder.get_object("video-container").unwrap(); 65 | 66 | let video_output = if !args.disable_gl && !CONFIG.read().unwrap().media.is_gl_disabled { 67 | gst::ElementFactory::make("gtkglsink", Some("gtkglsink")) 68 | .map(|gtkglsink| { 69 | let glsinkbin = gst::ElementFactory::make("glsinkbin", Some("video_sink")) 70 | .expect("PlaybackPipeline: couldn't get `glsinkbin` from `gtkglsink`"); 71 | glsinkbin 72 | .set_property("sink", >kglsink) 73 | .expect("VideoController: couldn't set `sink` for `glsinkbin`"); 74 | 75 | debug!("Using gtkglsink"); 76 | VideoOutput { 77 | sink: glsinkbin, 78 | widget: gtkglsink 79 | .get_property("widget") 80 | .expect("VideoController: couldn't get `widget` from `gtkglsink`") 81 | .get::() 82 | .expect("VideoController: unexpected type for `widget` in `gtkglsink`") 83 | .expect("VideoController: `widget` not found in `gtkglsink`"), 84 | } 85 | }) 86 | .ok() 87 | } else { 88 | None 89 | } 90 | .or_else(|| { 91 | gst::ElementFactory::make("gtksink", Some("video_sink")) 92 | .map(|sink| { 93 | debug!("Using gtksink"); 94 | VideoOutput { 95 | sink: sink.clone(), 96 | widget: sink 97 | .get_property("widget") 98 | .expect("VideoController: couldn't get `widget` from `gtksink`") 99 | .get::() 100 | .expect("VideoController: unexpected type for `widget` in `gtksink`") 101 | .expect("VideoController: `widget` not found in `gtksink`"), 102 | } 103 | }) 104 | .ok() 105 | }); 106 | 107 | if let Some(video_output) = video_output.as_ref() { 108 | container.pack_start(&video_output.widget, true, true, 0); 109 | container.reorder_child(&video_output.widget, 0); 110 | video_output.widget.show(); 111 | }; 112 | 113 | let mut video_ctrl = VideoController { 114 | video_output, 115 | container, 116 | cleaner_id: None, 117 | }; 118 | 119 | video_ctrl.cleanup(); 120 | 121 | video_ctrl 122 | } 123 | 124 | pub fn video_sink(&self) -> Option { 125 | self.video_output 126 | .as_ref() 127 | .map(|video_output| video_output.sink.clone()) 128 | } 129 | 130 | fn video_widget(&self) -> Option { 131 | self.video_output 132 | .as_ref() 133 | .map(|video_output| video_output.widget.clone()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/ui/video_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use glib::clone; 3 | use gtk::prelude::*; 4 | use log::error; 5 | 6 | use std::{cell::RefCell, rc::Rc}; 7 | 8 | use super::{spawn, MainController, UIDispatcher, UIEventSender, VideoController}; 9 | 10 | pub struct VideoDispatcher; 11 | impl UIDispatcher for VideoDispatcher { 12 | type Controller = VideoController; 13 | 14 | fn setup( 15 | video_ctrl: &mut VideoController, 16 | _main_ctrl_rc: &Rc>, 17 | _app: >k::Application, 18 | ui_event: &UIEventSender, 19 | ) { 20 | match video_ctrl.video_output { 21 | Some(ref video_output) => { 22 | // discard GStreamer defined navigation events on widget 23 | video_output 24 | .widget 25 | .set_events(gdk::EventMask::BUTTON_PRESS_MASK); 26 | 27 | video_ctrl.container.connect_button_press_event( 28 | clone!(@strong ui_event => move |_, _| { 29 | ui_event.play_pause(); 30 | Inhibit(true) 31 | }), 32 | ); 33 | } 34 | None => { 35 | error!("{}", gettext("Couldn't find GStreamer GTK video sink.")); 36 | let container = video_ctrl.container.clone(); 37 | spawn(async move { 38 | container.hide(); 39 | }); 40 | } 41 | }; 42 | } 43 | } 44 | --------------------------------------------------------------------------------