Add standalone media and remote downloads

This commit is contained in:
burnintuna 2026-07-21 21:18:36 +09:00
parent 8a213462e0
commit 5f9b5d9796
14 changed files with 683 additions and 86 deletions

2
Cargo.lock generated
View file

@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "comicviewer"
version = "0.1.4"
version = "0.2.0"
dependencies = [
"anyhow",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "comicviewer"
version = "0.1.4"
version = "0.2.0"
edition = "2024"
description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later"

View file

@ -1,7 +1,7 @@
# Comicviewer
Comicviewer is a GTK4 desktop application for browsing and reading comic image
archives stored locally or on SMB, FTP, and SFTP servers.
Comicviewer is a GTK4 desktop application for browsing ZIP archives, images,
and videos stored locally or on SMB, FTP, and SFTP servers.
한국어 문서:
@ -11,15 +11,16 @@ archives stored locally or on SMB, FTP, and SFTP servers.
The project is currently in early development. It supports saved local, SMB,
FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache,
natural sorting, safe on-demand image decoding and prefetching, single-page and
two-page viewing, continuous vertical scrolling, reading progress, and
XDG-compliant configuration.
two-page viewing, continuous vertical scrolling, standalone image and video
playback, reading progress, and XDG-compliant configuration.
## Development dependencies
On CachyOS or Arch Linux:
```sh
sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb
sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb gstreamer \
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
```
Run the application with:

View file

@ -26,6 +26,7 @@
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.2.0" date="2026-07-21" />
<release version="0.1.4" date="2026-07-21" />
<release version="0.1.3" date="2026-07-21" />
<release version="0.1.2" date="2026-07-21" />

View file

@ -1,7 +1,7 @@
# Comicviewer 설치 및 실행 안내
Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰어입니다.
로컬 파일뿐 아니라 SMB, FTP, SFTP 서버의 폴더와 ZIP 파일도 열 수 있습니다.
Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디어 뷰어입니다.
로컬 및 SMB, FTP, SFTP 위치의 ZIP, 이미지, 동영상 파일을 열 수 있습니다.
## 1. 필수 패키지
@ -9,34 +9,36 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
```sh
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
```
- `fuse2`: AppImage 마운트 및 실행
- `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩
- `gvfs`: FTP와 SFTP 접근
- `gvfs-smb`: SMB 공유 접근
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
다음 두 파일을 같은 폴더에 다운로드합니다.
- [Comicviewer-0.1.4-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz)
- [Comicviewer-0.1.4-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256)
- [Comicviewer-latest-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz)
- [Comicviewer-latest-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256)
터미널에서 직접 다운로드할 수도 있습니다.
```sh
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256
```
체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다.
```sh
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
tar -xzf Comicviewer-0.1.4-x86_64.tar.gz
cd Comicviewer-0.1.4-x86_64
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
tar -xzf Comicviewer-latest-x86_64.tar.gz
cd Comicviewer-latest-x86_64
./install-or-update.sh
```

View file

@ -7,10 +7,9 @@ Release 첨부 파일로 제공하는 방식을 권장합니다. 버전별 바
기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이
절차를 사용합니다.
태그, 프로젝트 버전, AppImage, 통합 번들의 버전은 반드시 같아야 합니다.
예를 들어 `v0.1.4` Release에는 이름에 `0.1.4`가 포함된 파일만 첨부합니다.
Git 태그만 푸시하면 첨부 파일은 자동으로 등록되지 않으므로, 마지막 단계에서
Release를 만들고 파일 네 개를 직접 첨부해야 합니다.
태그, 프로젝트 버전, AppImage의 내부 버전은 반드시 같아야 합니다. 사용자가
받는 통합 번들은 버전과 관계없이 `Comicviewer-latest-x86_64.tar.gz`라는 고정
이름을 사용합니다.
## 1. 버전 갱신
@ -56,9 +55,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
- `dist/Comicviewer-0.1.4-x86_64.AppImage.sha256`
- `dist/Comicviewer-0.1.4-x86_64.tar.gz`
- `dist/Comicviewer-0.1.4-x86_64.tar.gz.sha256`
- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256`
- `dist/Comicviewer-latest-x86_64.tar.gz`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘,
한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를
@ -67,9 +66,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
생성된 번들의 압축 목록과 체크섬을 확인합니다.
```sh
tar -tzf dist/Comicviewer-0.1.4-x86_64.tar.gz
tar -tzf dist/Comicviewer-latest-x86_64.tar.gz
cd dist
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
```
## 5. 소스 커밋과 태그
@ -79,10 +78,10 @@ sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
```sh
git add Cargo.toml Cargo.lock data packaging README.md docs
git commit -m "Prepare v0.1.4 release"
git tag -a v0.1.4 -m "Comicviewer v0.1.4"
git commit -m "Prepare v0.2.0 release"
git tag -a v0.2.0 -m "Comicviewer v0.2.0"
git push origin main
git push origin v0.1.4
git push origin v0.2.0
```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
@ -93,28 +92,29 @@ git push origin v0.1.4
Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을
`/www/Downloads/Comicviewer/`에 업로드합니다.
- `Comicviewer-0.1.4-x86_64.tar.gz`
- `Comicviewer-0.1.4-x86_64.tar.gz.sha256`
- `Comicviewer-latest-x86_64.tar.gz`
- `Comicviewer-latest-x86_64.tar.gz.sha256`
업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과
실제 설치를 확인합니다.
```text
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256
```
배포 안내에는 다음 내용을 사용합니다.
```text
지원 환경: CachyOS / Arch Linux x86_64
필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb
필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb, gstreamer,
gst-plugins-base, gst-plugins-good, gst-plugins-bad, gst-libav
설치 또는 업데이트:
위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다.
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
tar -xzf Comicviewer-0.1.4-x86_64.tar.gz
cd Comicviewer-0.1.4-x86_64
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
tar -xzf Comicviewer-latest-x86_64.tar.gz
cd Comicviewer-latest-x86_64
./install-or-update.sh
```

View file

@ -4,8 +4,9 @@ set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")"
version="${package_id##*@}"
bundle_name="Comicviewer-${version}-x86_64"
appimage_name="${bundle_name}.AppImage"
artifact_name="Comicviewer-${version}-x86_64"
bundle_name="Comicviewer-latest-x86_64"
appimage_name="${artifact_name}.AppImage"
appimage="${root_dir}/dist/${appimage_name}"
checksum="${appimage}.sha256"
archive="${root_dir}/dist/${bundle_name}.tar.gz"

View file

@ -39,7 +39,8 @@ install_dependencies() {
fi
set --
for package in fuse2 gtk4 dav1d gvfs gvfs-smb; do
for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \
gst-plugins-good gst-plugins-bad gst-libav; do
if ! pacman -Q "${package}" >/dev/null 2>&1; then
set -- "$@" "${package}"
fi

View file

@ -70,16 +70,33 @@ pub fn decode_page(path: &Path, page: &ArchivePage) -> Result<DecodedPage> {
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
}
let reader = ImageReader::new(Cursor::new(&encoded))
decode_image_bytes(&encoded, &page.name)
}
pub fn decode_image_file(path: &Path, display_name: &str) -> Result<DecodedPage> {
let file = File::open(path)
.with_context(|| format!("이미지 파일을 열 수 없습니다: {display_name}"))?;
let mut encoded = Vec::new();
file.take(MAX_ENTRY_SIZE + 1)
.read_to_end(&mut encoded)
.with_context(|| format!("이미지 파일을 읽을 수 없습니다: {display_name}"))?;
if encoded.len() as u64 > MAX_ENTRY_SIZE {
bail!("이미지 크기가 제한을 초과합니다: {display_name} (최대 256MB)");
}
decode_image_bytes(&encoded, display_name)
}
fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format()
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
let (width, height) = reader
.into_dimensions()
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
validate_dimensions(width, height, &page.name)?;
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
validate_dimensions(width, height, name)?;
let image = image::load_from_memory(&encoded)
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?;
let image = image::load_from_memory(encoded)
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
let rgba = image.into_rgba8();
Ok(DecodedPage {
@ -175,4 +192,16 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn decodes_a_standalone_image() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("photo.png");
let image = image::RgbaImage::from_pixel(4, 5, image::Rgba([1, 2, 3, 255]));
image.save(&path).unwrap();
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);
}
}

View file

@ -6,6 +6,15 @@ use std::time::SystemTime;
pub enum EntryKind {
Directory,
ZipArchive,
Image,
Video,
OtherFile,
}
impl EntryKind {
pub fn is_viewable(self) -> bool {
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
}
}
#[derive(Debug, Clone)]
@ -34,9 +43,12 @@ pub enum SortDirection {
pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
entries.sort_by(|left, right| {
let kind_order = match (left.kind, right.kind) {
(EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less,
(EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater,
let kind_order = match (
left.kind == EntryKind::Directory,
right.kind == EntryKind::Directory,
) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => Ordering::Equal,
};
if kind_order != Ordering::Equal {
@ -73,10 +85,19 @@ fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
}
}
pub fn is_zip_path(path: &Path) -> bool {
path.extension()
pub fn classify_file(path: &Path) -> EntryKind {
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("zip") => EntryKind::ZipArchive,
Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif") => EntryKind::Image,
Some("mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "mpeg" | "mpg" | "ogv") => {
EntryKind::Video
}
_ => EntryKind::OtherFile,
}
}
pub fn natural_cmp(left: &str, right: &str) -> Ordering {
@ -162,8 +183,17 @@ mod tests {
#[test]
fn zip_extension_is_case_insensitive() {
assert!(is_zip_path(Path::new("comic.ZIP")));
assert!(!is_zip_path(Path::new("comic.cbz")));
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive);
}
#[test]
fn classifies_common_media_extensions_case_insensitively() {
assert_eq!(classify_file(Path::new("photo.JPG")), EntryKind::Image);
assert_eq!(classify_file(Path::new("animation.webp")), EntryKind::Image);
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);
}
#[test]

View file

@ -7,7 +7,7 @@ use gtk::gio::prelude::*;
use gtk::glib;
use percent_encoding::percent_decode_str;
use crate::browser::{BrowserEntry, EntryKind, is_zip_path};
use crate::browser::{BrowserEntry, EntryKind, classify_file};
use crate::config::{LocationKind, LocationProfile};
const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
@ -115,9 +115,7 @@ where
let kind = match info.file_type() {
gio::FileType::Directory => EntryKind::Directory,
gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory,
gio::FileType::Regular if is_zip_path(Path::new(info.display_name().as_str())) => {
EntryKind::ZipArchive
}
gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())),
_ => continue,
};
let child = enumerator.child(&info);

View file

@ -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: &gtk::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: &gtk::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(&gtk::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);
}
}

View file

@ -1,4 +1,5 @@
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
@ -17,7 +18,7 @@ use crate::source::{
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
parent_within_root, root_file, validate_profile,
};
use crate::viewer::ViewerWindow;
use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow};
use crate::zip_cache::ZipCache;
pub struct ComicWindow {
@ -239,6 +240,13 @@ impl BrowserController {
self.list_box
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
let context_click = gtk::GestureClick::builder().button(3).build();
let controller = Rc::clone(self);
context_click.connect_pressed(move |_, _, _, y| {
controller.show_context_menu(y as i32);
});
self.list_box.add_controller(context_click);
let key_controller = gtk::EventControllerKey::new();
let controller = Rc::clone(self);
key_controller
@ -480,14 +488,71 @@ impl BrowserController {
self.search_entry.set_text("");
self.refresh();
}
EntryKind::ZipArchive => self.open_archive(entry),
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => {
let siblings = {
let state = self.state.borrow();
let mut entries = state
.entries
.iter()
.filter(|entry| entry.kind.is_viewable())
.cloned()
.collect::<Vec<_>>();
sort_entries(&mut entries, state.sort_field, state.sort_direction);
entries
};
let siblings = Rc::new(siblings);
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) {
self.open_viewable(entry, siblings, current);
}
}
EntryKind::OtherFile => {}
}
}
fn open_archive(self: &Rc<Self>, entry: BrowserEntry) {
fn open_viewable(
self: &Rc<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
) {
match entry.kind {
EntryKind::ZipArchive => self.open_archive(entry, siblings, current),
EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current),
_ => {}
}
}
fn sibling_navigator(
self: &Rc<Self>,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
) -> SiblingNavigator {
let controller = Rc::downgrade(self);
Rc::new(move |forward| {
let Some(controller) = controller.upgrade() else {
return false;
};
let Some(next) = sibling_index(current, siblings.len(), forward) else {
controller
.status_label
.set_text("더 이상 이동할 파일이 없습니다.");
return false;
};
controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next);
true
})
}
fn open_archive(
self: &Rc<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
) {
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
let progress_label = self.status_label.clone();
let controller = Rc::clone(self);
let navigator = self.sibling_navigator(siblings, current);
self.zip_cache.prepare(
entry,
move |current, total| {
@ -505,6 +570,7 @@ impl BrowserController {
source,
Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading,
Some(navigator),
);
}
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
@ -512,6 +578,138 @@ impl BrowserController {
);
}
fn open_media(
self: &Rc<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
) {
let kind = entry.kind;
self.status_label
.set_text("미디어 파일 다운로드 준비 중...");
let progress_label = self.status_label.clone();
let controller = Rc::clone(self);
let navigator = self.sibling_navigator(siblings, current);
self.zip_cache.prepare_media(
entry,
move |current, total| {
let percent = if total > 0 { current * 100 / total } else { 0 };
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
},
move |result| match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
let Some(application) = controller.window.application() else {
return;
};
MediaViewerWindow::open(&application, source, kind, Some(navigator));
}
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
},
);
}
fn show_context_menu(self: &Rc<Self>, y: i32) {
let Some(row) = self.list_box.row_at_y(y) else {
return;
};
let Some(entry) = self
.state
.borrow()
.visible_entries
.get(row.index() as usize)
.cloned()
else {
return;
};
if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() {
return;
}
self.list_box.select_row(Some(&row));
let popover = gtk::Popover::new();
popover.set_parent(&row);
let download_button = gtk::Button::with_label("다운로드...");
download_button.set_margin_top(6);
download_button.set_margin_bottom(6);
download_button.set_margin_start(6);
download_button.set_margin_end(6);
popover.set_child(Some(&download_button));
let controller = Rc::clone(self);
let popover_to_close = popover.clone();
download_button.connect_clicked(move |_| {
popover_to_close.popdown();
controller.choose_download_destination(entry.clone());
});
popover.popup();
}
fn choose_download_destination(self: &Rc<Self>, entry: BrowserEntry) {
let dialog = gtk::FileDialog::builder()
.title("다운로드 위치 선택")
.initial_name(&entry.name)
.build();
let controller = Rc::downgrade(self);
let parent = self.window.clone();
glib::MainContext::default().spawn_local(async move {
let result = dialog.save_future(Some(&parent)).await;
let Some(controller) = controller.upgrade() else {
return;
};
match result {
Ok(destination) if destination.is_native() => {
controller.download_remote_file(entry, destination);
}
Ok(_) => controller
.status_label
.set_text("로컬 파일 시스템 위치를 선택해 주세요."),
Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {}
Err(error) => controller
.status_label
.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")),
}
});
}
fn download_remote_file(&self, entry: BrowserEntry, destination: gio::File) {
let Some(final_path) = destination.path() else {
self.status_label
.set_text("로컬 파일 시스템 위치를 선택해 주세요.");
return;
};
let part_path = download_part_path(&final_path);
let source = gio::File::for_uri(&entry.uri);
let part_file = gio::File::for_path(&part_path);
let progress_label = self.status_label.clone();
let result_label = self.status_label.clone();
let expected_size = entry.size;
source.copy_async(
&part_file,
gio::FileCopyFlags::OVERWRITE,
glib::Priority::DEFAULT,
gio::Cancellable::NONE,
Some(Box::new(move |current, total| {
let percent = if total > 0 { current * 100 / total } else { 0 };
progress_label.set_text(&format!("{} 다운로드 중... {percent}%", entry.name));
})),
move |result| {
let completed = result.map_err(anyhow::Error::from).and_then(|()| {
let actual_size = fs::metadata(&part_path)?.len();
if expected_size.is_some_and(|expected| expected != actual_size) {
anyhow::bail!("다운로드 크기가 원본과 다릅니다.");
}
fs::rename(&part_path, &final_path)?;
Ok(())
});
if let Err(error) = completed {
let _ = fs::remove_file(&part_path);
result_label.set_text(&format!("다운로드 실패: {error:#}"));
} else {
result_label.set_text(&format!("다운로드 완료: {}", final_path.display()));
}
},
);
}
fn set_fast_image_loading(&self, switch: &gtk::Switch) {
let active = switch.is_active();
let previous = self.config.borrow().fast_image_loading;
@ -752,6 +950,9 @@ fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
let icon = match entry.kind {
EntryKind::Directory => "folder-symbolic",
EntryKind::ZipArchive => "package-x-generic-symbolic",
EntryKind::Image => "image-x-generic-symbolic",
EntryKind::Video => "video-x-generic-symbolic",
EntryKind::OtherFile => "text-x-generic-symbolic",
};
content.append(&gtk::Image::from_icon_name(icon));
content.append(
@ -790,6 +991,23 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
}
fn sibling_index(current: usize, count: usize, forward: bool) -> Option<usize> {
if forward {
(current + 1 < count).then_some(current + 1)
} else {
current.checked_sub(1)
}
}
fn download_part_path(final_path: &std::path::Path) -> PathBuf {
final_path.with_file_name(format!(
"{}.part",
final_path
.file_name()
.map_or_else(|| "download".into(), |name| name.to_string_lossy())
))
}
#[cfg(test)]
mod tests {
use super::*;
@ -819,4 +1037,20 @@ mod tests {
assert_eq!(find_prefix_index(&entries, "b", 2), Some(2));
assert_eq!(find_prefix_index(&entries, "b", 3), Some(1));
}
#[test]
fn sibling_navigation_stops_at_boundaries() {
assert_eq!(sibling_index(1, 3, false), Some(0));
assert_eq!(sibling_index(1, 3, true), Some(2));
assert_eq!(sibling_index(0, 3, false), None);
assert_eq!(sibling_index(2, 3, true), None);
}
#[test]
fn incomplete_download_uses_distinct_suffix() {
assert_eq!(
download_part_path(std::path::Path::new("/tmp/만화.zip")),
PathBuf::from("/tmp/만화.zip.part")
);
}
}

View file

@ -17,6 +17,7 @@ use crate::browser::BrowserEntry;
pub struct ArchiveSource {
pub local_path: PathBuf,
pub source_uri: String,
pub display_name: String,
_temporary: Option<Rc<tempfile::TempPath>>,
_pin: Option<Rc<CachePin>>,
}
@ -75,6 +76,7 @@ impl ZipCache {
callback(Ok(ArchiveSource {
local_path: path,
source_uri: entry.uri,
display_name: entry.name,
_temporary: None,
_pin: None,
}));
@ -82,24 +84,50 @@ impl ZipCache {
}
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
self.download_temporary(source, entry.uri, entry.size, progress, callback);
self.download_temporary(source, entry, true, progress, callback);
} else {
self.download_persistent(source, entry, progress, callback);
}
}
pub fn prepare_media<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
where
F: FnOnce(Result<ArchiveSource>) + 'static,
P: FnMut(i64, i64) + 'static,
{
let source = gio::File::for_uri(&entry.uri);
if let Some(path) = native_path(&source) {
callback(Ok(ArchiveSource {
local_path: path,
source_uri: entry.uri,
display_name: entry.name,
_temporary: None,
_pin: None,
}));
return;
}
self.download_temporary(source, entry, false, progress, callback);
}
fn download_temporary<F, P>(
self: &Rc<Self>,
source: gio::File,
source_uri: String,
expected_size: Option<u64>,
entry: BrowserEntry,
validate_zip: bool,
progress: P,
callback: F,
) where
F: FnOnce(Result<ArchiveSource>) + 'static,
P: FnMut(i64, i64) + 'static,
{
let temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() {
let suffix = Path::new(&entry.name)
.extension()
.and_then(|extension| extension.to_str())
.map_or_else(
|| ".part".to_owned(),
|extension| format!(".part.{extension}"),
);
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
Ok(file) => Rc::new(file.into_temp_path()),
Err(error) => {
callback(Err(error.into()));
@ -108,6 +136,7 @@ impl ZipCache {
};
let destination = gio::File::for_path(temporary.as_ref());
let retained = Rc::clone(&temporary);
let expected_size = entry.size;
source.copy_async(
&destination,
gio::FileCopyFlags::OVERWRITE,
@ -116,10 +145,14 @@ impl ZipCache {
Some(Box::new(progress)),
move |result| {
callback(result.map_err(Into::into).and_then(|()| {
validate_download(retained.as_ref(), expected_size)?;
validate_size(retained.as_ref(), expected_size)?;
if validate_zip {
validate_zip_file(retained.as_ref())?;
}
Ok(ArchiveSource {
local_path: retained.to_path_buf(),
source_uri,
source_uri: entry.uri,
display_name: entry.name,
_temporary: Some(retained),
_pin: None,
})
@ -143,7 +176,7 @@ impl ZipCache {
if final_path.is_file() {
if validate_download(&final_path, entry.size).is_ok() {
let _ = filetime::set_file_mtime(&final_path, FileTime::now());
callback(Ok(self.lease(final_path, entry.uri)));
callback(Ok(self.lease(final_path, entry.uri, entry.name)));
return;
}
let _ = fs::remove_file(&final_path);
@ -168,7 +201,7 @@ impl ZipCache {
)
})?;
cache.enforce_quota(&final_path)?;
Ok(cache.lease(final_path, entry.uri))
Ok(cache.lease(final_path, entry.uri, entry.name))
});
if result.is_err() {
let _ = fs::remove_file(&part_path);
@ -205,11 +238,17 @@ impl ZipCache {
Ok(())
}
fn lease(self: &Rc<Self>, path: PathBuf, source_uri: String) -> ArchiveSource {
fn lease(
self: &Rc<Self>,
path: PathBuf,
source_uri: String,
display_name: String,
) -> ArchiveSource {
*self.pinned.borrow_mut().entry(path.clone()).or_default() += 1;
ArchiveSource {
local_path: path.clone(),
source_uri,
display_name,
_temporary: None,
_pin: Some(Rc::new(CachePin {
path,
@ -224,6 +263,11 @@ fn native_path(file: &gio::File) -> Option<PathBuf> {
}
fn validate_download(path: &Path, expected_size: Option<u64>) -> Result<()> {
validate_size(path, expected_size)?;
validate_zip_file(path)
}
fn validate_size(path: &Path, expected_size: Option<u64>) -> Result<()> {
let actual_size = fs::metadata(path)?.len();
if let Some(expected_size) = expected_size
&& actual_size != expected_size
@ -232,6 +276,10 @@ fn validate_download(path: &Path, expected_size: Option<u64>) -> Result<()> {
"다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트"
);
}
Ok(())
}
fn validate_zip_file(path: &Path) -> Result<()> {
let file = File::open(path)?;
zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?;
Ok(())
@ -307,7 +355,11 @@ mod tests {
pinned: RefCell::new(HashMap::new()),
});
let lease = cache.lease(first.clone(), "file:///first.zip".into());
let lease = cache.lease(
first.clone(),
"file:///first.zip".into(),
"first.zip".into(),
);
cache.enforce_quota(&second).unwrap();
assert!(first.exists());
assert!(second.exists());