diff --git a/Cargo.lock b/Cargo.lock
index 28b8f59..8696120 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "comicviewer"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index c648663..72c8f1c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "comicviewer"
-version = "0.2.0"
+version = "0.2.1"
edition = "2024"
description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later"
diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml
index ad52674..84e954c 100644
--- a/data/io.github.comicviewer.Comicviewer.metainfo.xml
+++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml
@@ -26,6 +26,7 @@
+
diff --git a/docs/INSTALL.ko.md b/docs/INSTALL.ko.md
index 5ebeffd..a116d4c 100644
--- a/docs/INSTALL.ko.md
+++ b/docs/INSTALL.ko.md
@@ -79,4 +79,18 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다.
- FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다.
- 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다.
-- 기본 영구 ZIP 캐시 한도는 10GB이며 설정에서 변경할 수 있습니다.
+- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다.
+- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
+- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
+
+## 뷰어 조작
+
+- `[`와 `]`: 폴더의 이전/다음 ZIP·이미지·동영상 열기
+- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
+- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
+- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
+- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
+- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
+- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
+
+애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md
index befe386..8f8694b 100644
--- a/docs/RELEASING.ko.md
+++ b/docs/RELEASING.ko.md
@@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
-- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256`
+- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256`
- `dist/Comicviewer-latest-x86_64.tar.gz`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
@@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
```sh
git add Cargo.toml Cargo.lock data packaging README.md docs
-git commit -m "Prepare v0.2.0 release"
-git tag -a v0.2.0 -m "Comicviewer v0.2.0"
+git commit -m "Prepare v0.2.1 release"
+git tag -a v0.2.1 -m "Comicviewer v0.2.1"
git push origin main
-git push origin v0.2.0
+git push origin v0.2.1
```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
diff --git a/src/archive.rs b/src/archive.rs
index a3e5db0..eb7f661 100644
--- a/src/archive.rs
+++ b/src/archive.rs
@@ -1,9 +1,13 @@
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::Path;
+use std::time::Duration;
use anyhow::{Context, Result, bail};
-use image::ImageReader;
+use image::codecs::gif::GifDecoder;
+use image::codecs::webp::WebPDecoder;
+use image::metadata::LoopCount;
+use image::{AnimationDecoder, ImageFormat, ImageReader};
use zip::ZipArchive;
use crate::browser::natural_cmp_with_case;
@@ -11,6 +15,9 @@ use crate::browser::natural_cmp_with_case;
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 30_000;
const MAX_IMAGE_PIXELS: u64 = 100_000_000;
+const MAX_ANIMATION_FRAMES: usize = 1_000;
+const MAX_ANIMATION_BYTES: usize = 512 * 1024 * 1024;
+const MIN_FRAME_DELAY: Duration = Duration::from_millis(10);
#[derive(Debug, Clone)]
pub struct ArchivePage {
@@ -21,7 +28,13 @@ pub struct ArchivePage {
pub struct DecodedPage {
pub width: u32,
pub height: u32,
+ pub frames: Vec,
+ pub loop_count: Option,
+}
+
+pub struct DecodedFrame {
pub rgba: Vec,
+ pub delay: Duration,
}
pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> {
@@ -90,11 +103,33 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result {
let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format()
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
+ let format = reader.format();
let (width, height) = reader
.into_dimensions()
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
validate_dimensions(width, height, name)?;
+ let animated = match format {
+ Some(ImageFormat::Gif) => {
+ let decoder = GifDecoder::new(Cursor::new(encoded))
+ .with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?;
+ let loop_count = animation_loop_count(decoder.loop_count());
+ Some((decoder.into_frames(), loop_count))
+ }
+ Some(ImageFormat::WebP) => {
+ let decoder = WebPDecoder::new(Cursor::new(encoded))
+ .with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?;
+ decoder.has_animation().then(|| {
+ let loop_count = animation_loop_count(decoder.loop_count());
+ (decoder.into_frames(), loop_count)
+ })
+ }
+ _ => None,
+ };
+ if let Some((frames, loop_count)) = animated {
+ return decoded_animation(width, height, frames, loop_count, name);
+ }
+
let image = image::load_from_memory(encoded)
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
let rgba = image.into_rgba8();
@@ -102,10 +137,69 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result {
Ok(DecodedPage {
width,
height,
- rgba: rgba.into_raw(),
+ frames: vec![DecodedFrame {
+ rgba: rgba.into_raw(),
+ delay: Duration::ZERO,
+ }],
+ loop_count: Some(1),
})
}
+fn decoded_animation(
+ width: u32,
+ height: u32,
+ frames: impl Iterator- >,
+ loop_count: Option,
+ name: &str,
+) -> Result {
+ let expected_bytes = usize::try_from(width)?
+ .checked_mul(usize::try_from(height)?)
+ .and_then(|pixels| pixels.checked_mul(4))
+ .context("애니메이션 프레임 크기를 처리할 수 없습니다")?;
+ let mut decoded = Vec::new();
+ let mut total_bytes = 0_usize;
+ for (index, frame) in frames.enumerate() {
+ if index >= MAX_ANIMATION_FRAMES {
+ bail!(
+ "애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)"
+ );
+ }
+ let frame =
+ frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?;
+ total_bytes = total_bytes
+ .checked_add(expected_bytes)
+ .context("애니메이션 전체 크기를 처리할 수 없습니다")?;
+ if total_bytes > MAX_ANIMATION_BYTES {
+ bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)");
+ }
+ let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY);
+ let rgba = frame.into_buffer();
+ if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes {
+ bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}");
+ }
+ decoded.push(DecodedFrame {
+ rgba: rgba.into_raw(),
+ delay,
+ });
+ }
+ if decoded.is_empty() {
+ bail!("애니메이션에 표시할 프레임이 없습니다: {name}");
+ }
+ Ok(DecodedPage {
+ width,
+ height,
+ frames: decoded,
+ loop_count,
+ })
+}
+
+fn animation_loop_count(loop_count: LoopCount) -> Option {
+ match loop_count {
+ LoopCount::Infinite => None,
+ LoopCount::Finite(count) => Some(count.get()),
+ }
+}
+
fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> {
let pixels = u64::from(width) * u64::from(height);
if width == 0
@@ -188,7 +282,7 @@ mod tests {
);
let decoded = decode_page(&path, &pages[0]).unwrap();
assert_eq!((decoded.width, decoded.height), (2, 3));
- assert_eq!(decoded.rgba.len(), 2 * 3 * 4);
+ assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4);
std::fs::remove_file(path).unwrap();
}
@@ -202,6 +296,37 @@ mod tests {
let decoded = decode_image_file(&path, "photo.png").unwrap();
assert_eq!((decoded.width, decoded.height), (4, 5));
- assert_eq!(decoded.rgba.len(), 4 * 5 * 4);
+ assert_eq!(decoded.frames[0].rgba.len(), 4 * 5 * 4);
+ }
+
+ #[test]
+ fn decodes_all_animated_gif_frames() {
+ let mut encoded = Vec::new();
+ let first = image::Frame::from_parts(
+ image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])),
+ 0,
+ 0,
+ image::Delay::from_numer_denom_ms(20, 1),
+ );
+ let second = image::Frame::from_parts(
+ image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])),
+ 0,
+ 0,
+ image::Delay::from_numer_denom_ms(30, 1),
+ );
+ let mut encoder = image::codecs::gif::GifEncoder::new(&mut encoded);
+ encoder
+ .set_repeat(image::codecs::gif::Repeat::Finite(2))
+ .unwrap();
+ encoder.encode_frames([first, second]).unwrap();
+ drop(encoder);
+
+ let decoded = decode_image_bytes(&encoded, "animated.gif").unwrap();
+
+ assert_eq!(decoded.frames.len(), 2);
+ assert_eq!(decoded.frames[0].delay, Duration::from_millis(20));
+ assert_eq!(decoded.frames[1].delay, Duration::from_millis(30));
+ assert_eq!(decoded.loop_count, Some(2));
+ assert_ne!(decoded.frames[0].rgba, decoded.frames[1].rgba);
}
}
diff --git a/src/browser.rs b/src/browser.rs
index defbd1d..d3f31e3 100644
--- a/src/browser.rs
+++ b/src/browser.rs
@@ -15,10 +15,6 @@ impl EntryKind {
pub fn is_viewable(self) -> bool {
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
}
-
- pub fn is_media(self) -> bool {
- matches!(self, Self::Image | Self::Video)
- }
}
#[derive(Debug, Clone)]
@@ -229,10 +225,6 @@ mod tests {
assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video);
assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video);
assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile);
- assert!(EntryKind::Image.is_media());
- assert!(EntryKind::Video.is_media());
- assert!(!EntryKind::ZipArchive.is_media());
- assert!(!EntryKind::OtherFile.is_media());
}
#[test]
diff --git a/src/viewer.rs b/src/viewer.rs
index ac33744..883ab74 100644
--- a/src/viewer.rs
+++ b/src/viewer.rs
@@ -1,6 +1,6 @@
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet, VecDeque};
-use std::rc::Rc;
+use std::rc::{Rc, Weak};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::Duration;
@@ -18,20 +18,89 @@ const CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024;
const CACHE_LIMIT_PAGES: usize = 16;
const FAST_CACHE_LIMIT_BYTES: usize = 1024 * 1024 * 1024;
const FAST_CACHE_LIMIT_PAGES: usize = 64;
+const VIDEO_SEEK_STEP_US: i64 = 5_000_000;
+const VIDEO_VOLUME_STEP: f64 = 0.05;
+const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller";
+const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller";
+
+fn clear_viewer_controller(window: >k::ApplicationWindow) {
+ unsafe {
+ if let Some(controller) = window.steal_data::>(VIEWER_CONTROLLER_KEY) {
+ controller.stop_animations();
+ }
+ if let Some(controller) =
+ window.steal_data::>(MEDIA_VIEWER_CONTROLLER_KEY)
+ {
+ controller.stop_playback();
+ }
+ }
+}
+
+fn ensure_viewer_close_handler(window: >k::ApplicationWindow) {
+ if window.has_css_class("viewer-close-handler") {
+ return;
+ }
+ window.add_css_class("viewer-close-handler");
+ window.connect_close_request(|window| {
+ clear_viewer_controller(window);
+ glib::Propagation::Proceed
+ });
+}
+
+pub(crate) fn set_viewer_header(window: >k::ApplicationWindow, header: >k::HeaderBar) {
+ let host = window
+ .titlebar()
+ .and_downcast::()
+ .unwrap_or_else(|| {
+ let host = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ host.set_hexpand(true);
+ window.set_titlebar(Some(&host));
+ host
+ });
+ while let Some(child) = host.first_child() {
+ host.remove(&child);
+ }
+ header.set_hexpand(true);
+ host.append(header);
+}
pub struct ViewerWindow;
pub struct SiblingNavigator {
position: Rc Option<(usize, usize)>>,
navigate: Rc bool>,
+ files: Rc Vec>,
+ actions: Rc,
+}
+
+type SiblingActionResolver = dyn Fn(&str) -> Option;
+
+#[derive(Clone)]
+pub struct SiblingFile {
+ pub uri: String,
+ pub name: String,
+ pub kind: EntryKind,
+}
+
+#[derive(Clone)]
+pub struct SiblingFileActions {
+ pub favorite: FavoriteControl,
+ pub download: FileDownloadControl,
}
impl SiblingNavigator {
pub fn new(
position: Rc Option<(usize, usize)>>,
navigate: Rc bool>,
+ files: Rc Vec>,
+ actions: Rc,
) -> Self {
- Self { position, navigate }
+ Self {
+ position,
+ navigate,
+ files,
+ actions,
+ }
}
fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
@@ -56,6 +125,22 @@ impl SiblingNavigator {
fn position(&self) -> Option<(usize, usize)> {
(self.position)()
}
+
+ fn files(&self) -> Vec {
+ (self.files)()
+ }
+
+ fn open_uri(&self, uri: &str, window: gtk::ApplicationWindow) -> bool {
+ let files = self.files();
+ let Some(index) = files.iter().position(|file| file.uri == uri) else {
+ return false;
+ };
+ self.open_index(index, window)
+ }
+
+ fn actions(&self, uri: &str) -> Option {
+ (self.actions)(uri)
+ }
}
#[derive(Clone, Copy)]
@@ -77,11 +162,13 @@ impl SiblingTarget {
}
}
+#[derive(Clone)]
pub struct FavoriteControl {
pub active: bool,
pub toggle: Rc bool>,
}
+#[derive(Clone)]
pub struct FileDownloadControl {
pub download: Rc,
}
@@ -117,6 +204,9 @@ impl ViewerWindow {
view_mode_control: _,
} = controls;
let title = source.display_name.clone();
+ if let Some(window) = reuse_window.as_ref() {
+ clear_viewer_controller(window);
+ }
let window = reuse_window.unwrap_or_else(|| {
gtk::ApplicationWindow::builder()
.application(application)
@@ -125,14 +215,19 @@ impl ViewerWindow {
.default_height(850)
.build()
});
+ ensure_viewer_close_handler(&window);
window.set_title(Some(&title));
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(>k::Label::new(Some(&title))));
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
let download_button = gtk::Button::with_label("파일 다운로드");
+ let file_list_button = gtk::ToggleButton::with_label("파일 목록");
+ file_list_button.set_sensitive(sibling_navigator.is_some());
+ file_list_button.set_active(window.has_css_class("viewer-file-list-visible"));
header.pack_end(&download_button);
header.pack_end(&favorite_button);
- window.set_titlebar(Some(&header));
+ header.pack_end(&file_list_button);
+ set_viewer_header(&window, &header);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
@@ -224,7 +319,14 @@ impl ViewerWindow {
window_overlay.set_child(Some(&root));
let (navigation_message, navigation_message_label) = navigation_message();
window_overlay.add_overlay(&navigation_message);
- window.set_child(Some(&window_overlay));
+ window_overlay.set_hexpand(true);
+ window_overlay.set_vexpand(true);
+ let file_list = viewer_file_list();
+ file_list.panel.set_visible(file_list_button.is_active());
+ let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0);
+ viewer_layout.append(&window_overlay);
+ viewer_layout.append(&file_list.panel);
+ window.set_child(Some(&viewer_layout));
let controller = Rc::new(ViewerController {
window,
@@ -252,6 +354,10 @@ impl ViewerWindow {
fast_image_loading,
case_insensitive_sort,
sibling_navigator,
+ file_list_button,
+ file_list_panel: file_list.panel,
+ file_list: file_list.list,
+ file_list_files: RefCell::new(Vec::new()),
favorite_button,
favorite_toggle: favorite_control.toggle,
download_button,
@@ -266,6 +372,9 @@ impl ViewerWindow {
navigation_message_generation: Rc::new(Cell::new(0)),
continuous_programmatic_scroll: Cell::new(false),
view_layout_generation: Cell::new(0),
+ single_animation_generation: Rc::new(AnimationState::default()),
+ left_animation_generation: Rc::new(AnimationState::default()),
+ right_animation_generation: Rc::new(AnimationState::default()),
state: RefCell::new(ViewerState {
pages: Vec::new(),
anchor: 0,
@@ -280,6 +389,14 @@ impl ViewerWindow {
}),
});
controller.connect_signals();
+ if controller.file_list_button.is_active() {
+ controller.refresh_file_list();
+ }
+ unsafe {
+ controller
+ .window
+ .set_data(VIEWER_CONTROLLER_KEY, Rc::clone(&controller));
+ }
controller.apply_view_mode();
controller.window.present();
controller.load_archive();
@@ -295,16 +412,55 @@ enum ViewMode {
Continuous,
}
-struct CachedPage {
+#[derive(Clone)]
+struct CachedFrame {
texture: gdk::MemoryTexture,
+ delay: Duration,
+}
+
+struct CachedAnimation {
+ frames: Vec,
+ loop_count: Option,
+}
+
+impl std::ops::Deref for CachedAnimation {
+ type Target = [CachedFrame];
+
+ fn deref(&self) -> &Self::Target {
+ &self.frames
+ }
+}
+
+struct CachedPage {
+ frames: Rc,
cost: usize,
last_used: u64,
}
+#[derive(Default)]
+struct AnimationState {
+ generation: Cell,
+ timer: RefCell