Add standalone media and remote downloads
This commit is contained in:
parent
8a213462e0
commit
5f9b5d9796
14 changed files with 683 additions and 86 deletions
278
src/viewer.rs
278
src/viewer.rs
|
|
@ -9,7 +9,8 @@ use gtk::gdk;
|
|||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::archive::{ArchivePage, DecodedPage, decode_page, list_pages};
|
||||
use crate::archive::{ArchivePage, DecodedPage, decode_image_file, decode_page, list_pages};
|
||||
use crate::browser::EntryKind;
|
||||
use crate::database::Database;
|
||||
use crate::zip_cache::ArchiveSource;
|
||||
|
||||
|
|
@ -20,18 +21,17 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64;
|
|||
|
||||
pub struct ViewerWindow;
|
||||
|
||||
pub type SiblingNavigator = Rc<dyn Fn(bool) -> bool>;
|
||||
|
||||
impl ViewerWindow {
|
||||
pub fn open(
|
||||
application: >k::Application,
|
||||
source: ArchiveSource,
|
||||
database: Rc<Database>,
|
||||
fast_image_loading: bool,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
) {
|
||||
let title = source
|
||||
.local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Comicviewer".into());
|
||||
let title = source.display_name.clone();
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(application)
|
||||
.title(&title)
|
||||
|
|
@ -119,8 +119,14 @@ impl ViewerWindow {
|
|||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0);
|
||||
page_seek.set_draw_value(false);
|
||||
page_seek.set_sensitive(false);
|
||||
page_seek.set_margin_start(8);
|
||||
page_seek.set_margin_end(8);
|
||||
root.append(&toolbar);
|
||||
root.append(&overlay);
|
||||
root.append(&page_seek);
|
||||
root.append(&status_label);
|
||||
window.set_child(Some(&root));
|
||||
|
||||
|
|
@ -136,6 +142,7 @@ impl ViewerWindow {
|
|||
continuous_scrolled,
|
||||
spinner,
|
||||
page_label,
|
||||
page_seek,
|
||||
status_label,
|
||||
previous_button,
|
||||
next_button,
|
||||
|
|
@ -144,6 +151,7 @@ impl ViewerWindow {
|
|||
mode_dropdown,
|
||||
direction_dropdown,
|
||||
fast_image_loading,
|
||||
sibling_navigator,
|
||||
state: RefCell::new(ViewerState {
|
||||
pages: Vec::new(),
|
||||
anchor: 0,
|
||||
|
|
@ -210,6 +218,7 @@ struct ViewerController {
|
|||
continuous_scrolled: gtk::ScrolledWindow,
|
||||
spinner: gtk::Spinner,
|
||||
page_label: gtk::Label,
|
||||
page_seek: gtk::Scale,
|
||||
status_label: gtk::Label,
|
||||
previous_button: gtk::Button,
|
||||
next_button: gtk::Button,
|
||||
|
|
@ -218,6 +227,7 @@ struct ViewerController {
|
|||
mode_dropdown: gtk::DropDown,
|
||||
direction_dropdown: gtk::DropDown,
|
||||
fast_image_loading: bool,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
state: RefCell<ViewerState>,
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +251,14 @@ impl ViewerController {
|
|||
let controller = Rc::clone(self);
|
||||
self.direction_dropdown
|
||||
.connect_selected_notify(move |_| controller.render());
|
||||
let controller = Rc::clone(self);
|
||||
self.page_seek.connect_value_changed(move |scale| {
|
||||
if let Some(index) =
|
||||
page_seek_index(scale.value(), controller.state.borrow().pages.len())
|
||||
{
|
||||
controller.seek_page(index);
|
||||
}
|
||||
});
|
||||
|
||||
let adjustment = self.continuous_scrolled.vadjustment();
|
||||
let controller = Rc::clone(self);
|
||||
|
|
@ -278,6 +296,14 @@ impl ViewerController {
|
|||
controller.navigate(true);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ if key.to_unicode() == Some('[') => {
|
||||
controller.open_sibling(false);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ if key.to_unicode() == Some(']') => {
|
||||
controller.open_sibling(true);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ if key
|
||||
.to_unicode()
|
||||
.is_some_and(|character| character.eq_ignore_ascii_case(&'f')) =>
|
||||
|
|
@ -294,6 +320,14 @@ impl ViewerController {
|
|||
self.window.add_controller(key_controller);
|
||||
}
|
||||
|
||||
fn open_sibling(&self, forward: bool) {
|
||||
if let Some(navigate) = &self.sibling_navigator
|
||||
&& navigate(forward)
|
||||
{
|
||||
self.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen(&self) {
|
||||
if self.window.is_fullscreen() {
|
||||
self.window.unfullscreen();
|
||||
|
|
@ -332,6 +366,7 @@ impl ViewerController {
|
|||
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
|
||||
}
|
||||
Ok(pages) => {
|
||||
let page_count = pages.len();
|
||||
let saved = self
|
||||
.database
|
||||
.load_progress(&self.source.source_uri)
|
||||
|
|
@ -343,6 +378,8 @@ impl ViewerController {
|
|||
state.pages = pages;
|
||||
state.anchor = saved;
|
||||
drop(state);
|
||||
self.page_seek.set_range(0.0, (page_count - 1) as f64);
|
||||
self.page_seek.set_sensitive(page_count > 1);
|
||||
self.build_continuous_rows();
|
||||
self.render();
|
||||
}
|
||||
|
|
@ -461,6 +498,23 @@ impl ViewerController {
|
|||
self.render();
|
||||
}
|
||||
|
||||
fn seek_page(self: &Rc<Self>, index: usize) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.pages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let index = index.min(state.pages.len() - 1);
|
||||
if index == state.anchor {
|
||||
return;
|
||||
}
|
||||
state.anchor = index;
|
||||
drop(state);
|
||||
self.render();
|
||||
if self.view_mode() == ViewMode::Continuous {
|
||||
self.schedule_scroll_to_anchor();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self: &Rc<Self>) {
|
||||
let visible = self.visible_indices();
|
||||
if visible.is_empty() {
|
||||
|
|
@ -838,26 +892,209 @@ impl ViewerController {
|
|||
}
|
||||
|
||||
fn update_navigation(&self) {
|
||||
let state = self.state.borrow();
|
||||
let count = state.pages.len();
|
||||
let (count, anchor) = {
|
||||
let state = self.state.borrow();
|
||||
(state.pages.len(), state.anchor)
|
||||
};
|
||||
if count == 0 {
|
||||
self.page_label.set_text("- / -");
|
||||
return;
|
||||
}
|
||||
let visible = self.visible_indices();
|
||||
let end = visible.last().copied().unwrap_or(state.anchor) + 1;
|
||||
let end = visible.last().copied().unwrap_or(anchor) + 1;
|
||||
if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage {
|
||||
self.page_label
|
||||
.set_text(&format!("{}-{end} / {count}", state.anchor + 1));
|
||||
.set_text(&format!("{}-{end} / {count}", anchor + 1));
|
||||
} else {
|
||||
self.page_label
|
||||
.set_text(&format!("{} / {count}", state.anchor + 1));
|
||||
.set_text(&format!("{} / {count}", anchor + 1));
|
||||
}
|
||||
self.previous_button.set_sensitive(state.anchor > 0);
|
||||
self.previous_button.set_sensitive(anchor > 0);
|
||||
self.next_button.set_sensitive(end < count);
|
||||
self.shift_back_button.set_sensitive(state.anchor > 0);
|
||||
self.shift_forward_button
|
||||
.set_sensitive(state.anchor + 1 < count);
|
||||
self.shift_back_button.set_sensitive(anchor > 0);
|
||||
self.shift_forward_button.set_sensitive(anchor + 1 < count);
|
||||
if (self.page_seek.value() - anchor as f64).abs() >= f64::EPSILON {
|
||||
self.page_seek.set_value(anchor as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaViewerWindow;
|
||||
|
||||
impl MediaViewerWindow {
|
||||
pub fn open(
|
||||
application: >k::Application,
|
||||
source: ArchiveSource,
|
||||
kind: EntryKind,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
) {
|
||||
let title = source.display_name.clone();
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(application)
|
||||
.title(&title)
|
||||
.default_width(1100)
|
||||
.default_height(850)
|
||||
.build();
|
||||
let header = gtk::HeaderBar::new();
|
||||
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let stack = gtk::Stack::new();
|
||||
stack.set_hexpand(true);
|
||||
stack.set_vexpand(true);
|
||||
let picture = page_picture();
|
||||
stack.add_named(&picture, Some("image"));
|
||||
let video = if kind == EntryKind::Video {
|
||||
gtk::Video::for_filename(Some(&source.local_path))
|
||||
} else {
|
||||
gtk::Video::new()
|
||||
};
|
||||
video.set_autoplay(true);
|
||||
video.set_hexpand(true);
|
||||
video.set_vexpand(true);
|
||||
stack.add_named(&video, Some("video"));
|
||||
let status_label = gtk::Label::builder()
|
||||
.label(&title)
|
||||
.xalign(0.0)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
root.append(&stack);
|
||||
root.append(&status_label);
|
||||
window.set_child(Some(&root));
|
||||
|
||||
let controller = Rc::new(MediaViewerController {
|
||||
window,
|
||||
source,
|
||||
picture,
|
||||
video,
|
||||
stack,
|
||||
status_label,
|
||||
sibling_navigator,
|
||||
});
|
||||
if let Some(stream) = controller.video.media_stream() {
|
||||
let status_label = controller.status_label.clone();
|
||||
stream.connect_error_notify(move |stream| {
|
||||
if let Some(error) = stream.error() {
|
||||
status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
controller.connect_signals();
|
||||
controller.window.present();
|
||||
match kind {
|
||||
EntryKind::Image => controller.load_image(),
|
||||
EntryKind::Video => {
|
||||
controller.stack.set_visible_child_name("video");
|
||||
if let Some(stream) = controller.video.media_stream() {
|
||||
stream.play();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaViewerController {
|
||||
window: gtk::ApplicationWindow,
|
||||
source: ArchiveSource,
|
||||
picture: gtk::Picture,
|
||||
video: gtk::Video,
|
||||
stack: gtk::Stack,
|
||||
status_label: gtk::Label,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
}
|
||||
|
||||
impl MediaViewerController {
|
||||
fn connect_signals(self: &Rc<Self>) {
|
||||
let keys = gtk::EventControllerKey::new();
|
||||
let controller = Rc::clone(self);
|
||||
keys.connect_key_pressed(move |_, key, _, _| match key.to_unicode() {
|
||||
Some('[') => {
|
||||
controller.open_sibling(false);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Some(']') => {
|
||||
controller.open_sibling(true);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
Some(character) if character.eq_ignore_ascii_case(&'f') => {
|
||||
controller.toggle_fullscreen();
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ if key == gdk::Key::Escape && controller.window.is_fullscreen() => {
|
||||
controller.window.unfullscreen();
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ => glib::Propagation::Proceed,
|
||||
});
|
||||
self.window.add_controller(keys);
|
||||
}
|
||||
|
||||
fn open_sibling(&self, forward: bool) {
|
||||
if let Some(navigate) = &self.sibling_navigator
|
||||
&& navigate(forward)
|
||||
{
|
||||
if let Some(stream) = self.video.media_stream() {
|
||||
stream.pause();
|
||||
}
|
||||
self.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen(&self) {
|
||||
if self.window.is_fullscreen() {
|
||||
self.window.unfullscreen();
|
||||
} else {
|
||||
self.window.fullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(self: &Rc<Self>) {
|
||||
self.stack.set_visible_child_name("image");
|
||||
self.status_label.set_text("이미지를 읽는 중...");
|
||||
let path = self.source.local_path.clone();
|
||||
let display_name = self.source.display_name.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let result =
|
||||
decode_image_file(&path, &display_name).map_err(|error| format!("{error:#}"));
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
let controller = Rc::downgrade(self);
|
||||
glib::timeout_add_local(Duration::from_millis(20), move || {
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
if let Some(controller) = controller.upgrade() {
|
||||
controller.finish_image_load(result);
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn finish_image_load(&self, result: Result<DecodedPage, String>) {
|
||||
match result {
|
||||
Ok(page) => {
|
||||
let bytes = glib::Bytes::from_owned(page.rgba);
|
||||
let texture = gdk::MemoryTexture::new(
|
||||
page.width as i32,
|
||||
page.height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
page.width as usize * 4,
|
||||
);
|
||||
self.picture.set_paintable(Some(&texture));
|
||||
self.status_label.set_text(&self.source.display_name);
|
||||
}
|
||||
Err(error) => self.status_label.set_text(&error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -918,6 +1155,10 @@ fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Ve
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn page_seek_index(value: f64, count: usize) -> Option<usize> {
|
||||
(count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -960,4 +1201,11 @@ mod tests {
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_seek_rounds_and_clamps_to_archive() {
|
||||
assert_eq!(page_seek_index(3.6, 10), Some(4));
|
||||
assert_eq!(page_seek_index(20.0, 10), Some(9));
|
||||
assert_eq!(page_seek_index(0.0, 0), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue