Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 140 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
# Chytanka

**Chytanka** is a versatile and user-friendly PWA for reading manga, comics, and other visual stories. Whether you prefer to read from popular online platforms, your own server, or local files, Chytanka is here to enhance your reading experience.
**Chytanka** is a lightweight, privacy-friendly PWA for reading manga, comics, and visual stories — from online sources, your own server, or local files.

No accounts. No tracking. Just reading.

## 🚀 Get Started

👉 [https://chytanka.ink](https://chytanka.ink)

## ⚡ Why Chytanka?

- 🌍 Open anything — links, APIs, or local files
- 📂 No uploads — your files stay on your device
- 🧠 Smart behavior — auto-detect reading mode via tags
- 🎮 Gamepad ready — full control without mouse
- 🌓 Comfort first — night filter, fullscreen, responsive

## 🖼️ Preview

![Chytanka Screenshot](./src/assets/screenshots/12fps-drop-file.webp)

## Features

Expand All @@ -22,7 +40,7 @@ Chytanka supports opening episodes from the following platforms:
- [ ] [Bluesky](https://bsky.app)
<!-- - [ ] [Catbox](https://catbox.moe/) -->

### 🌐 **Custom JSON API**
#### 🌐 **Custom JSON API**

Chytanka can open episodes from any custom JSON API returning the following format:

Expand All @@ -44,7 +62,7 @@ Chytanka can open episodes from any custom JSON API returning the following form
}
```

### 📚 **Create and Share Readlists**
#### 📚 **Create and Share Readlists**

Compile a readlist using [Chytanka Readlist Creator](https://chytanka.ink/list):

Expand Down Expand Up @@ -85,7 +103,7 @@ Immerse yourself in reading with a fullscreen option.
### 🕒 **Viewing History**

- [x] Tracks history of supported links.
- [ ] File history support is planned.
- [x] File history support is planned.

### ⌨️ **Keyboard Shortcuts**

Expand Down Expand Up @@ -138,32 +156,137 @@ Chytanka includes built-in support for gamepads (tested with PlayStation-style c

If supported by the API, Chytanka warns users about NSFW content.

### 🏷️ Tags in Titles

Chytanka supports special tags inside file names or episode titles to automatically control viewer behavior.

Tags are parsed from the title and applied on load.

#### 📌 Supported Tags

You can include any of the following words in the title:

```
rtl, ltr, ver, long, scroll, nsfw, sfw, color, bw, demo, extra
```

Example:

```
My Manga Vol.1 [rtl][nsfw]
Chapter 5 - [long][scroll]
Demo Episode [ltr]
```

#### 🎯 What Tags Do

##### 📖 View Mode

These tags control how pages are displayed:

| Tag | Description | Mode |
| ----------- | ----------------------- | ---- |
| `[rtl]` | Right-to-left reading | 1 |
| `[ltr]` | Left-to-right reading | 2 |
| `[ver]` | Vertical / webtoon mode | 3 |
| `[long]` | Vertical / long strip | 3 |
| `[scroll]` | Vertical scrolling | 3 |

> If multiple tags are present, the first matched tag wins.

##### 🔞 Content Flags

| Tag | Effect |
| -------- | --------------------- |
| `[nsfw]` | Marks episode as NSFW |
| `[sfw]` | (Reserved / optional) |

##### 🎨 Additional Tags (for future use)

These tags are parsed but may be used later:

- `[color]` – colored pages
- `[bw]` – black & white
- `[demo]` – demo content
- `[extra]` – bonus materials

#### ⚙️ How It Works

- Tags are case-insensitive
- Tags are detected as standalone words (e.g. rtl, not ultra)
- Parsed automatically from:
- file name
- episode title

#### 💡 Example

```
Attack on Titan - Chapter 1 [rtl][nsfw].cbz
```

Result:

- View mode → Right-to-left
- NSFW flag → enabled

#### 🧠 Notes

- *Tags are processed on episode load*
- *They override default viewer settings*
- *Designed for compatibility with local files (.cbz, .zip) and remote sources*

### 🖇️ **Embed Chytanka on Your Website**

Embed Chytanka using an iframe and interact with it via `postMessage`. Learn more in the [Embedding Guide](https://github.com/chytanka/chytanka.github.io/wiki/Embedding-Chytanka-on-Your-Website).
You can embed Chytanka into your website and control it via `postMessage`.

Perfect for:

- manga sites
- blogs
- personal collections

Learn more in the [Embedding Guide](https://github.com/chytanka/chytanka.github.io/wiki/Embedding-Chytanka-on-Your-Website).

---

## Development server
## 🛠️ Development

Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
Chytanka requires a proxy server for handling some external sources.
Proxy repository: [https://github.com/chytanka/proxy](https://github.com/chytanka/proxy)

## Code scaffolding
> ⚠️ *Important*: Both Chytanka and the proxy must run on the *same host.*

Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
### ▶️ Run locally on Windows

## Build
```bash
# Start proxy on the same host
set NODE_ENV=dev&& set HOST=192.168.0.0 && node index.js
```

```bash
# Start Chytanka app
ng serve --host 192.168.0.0
```

Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
### 💡 Notes

## Running unit tests
- *The proxy handles CORS and headers for external image sources.*
- *Make sure both services use the same host IP (e.g., 192.168.0.0).*
- *Adjust the host if your network requires a different local IP.*

Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
### 🧠 Why a Proxy?

## Running end-to-end tests
- Bypasses CORS restrictions from image hosts
- Normalizes headers
- Ensures safe and consistent fetching of external content

Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## 🔗 Other Chytanka Projects

## Further help
Chytanka is more than just a reader — it’s an ecosystem. Check out these companion projects:

To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
| Project | What it does | Repository |
| ------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **Meta Image Generator** | Generates social preview images (meta tags) for episodes | [chytanka-meta-image](https://github.com/chytanka/chytanka-meta-image) |
| **Chytanka Helper** | Browser extension: adds a button to open supported site links directly in Chytanka | [chytanka-helper](https://github.com/chytanka/chytanka-hepler) |
| **Opera GX Theme** | Custom theme for Opera GX tailored to Chytanka | [chytanka-gx-mod](https://github.com/chytanka/chytanka-gx-mod) |
| **Chytanka Make** | Create and edit CBZ files from images, reorder pages, add metadata | [chytanka-make](https://github.com/chytanka/chytanka-make) |
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "chytanka",
"version": "0.13.51",
"version": "0.13.54",
"scripts": {
"ng": "ng",
"start": "ng serve",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@if(episode$() | async; as episode){

@if (!(loading$() | async)) {
<app-viewer [playlistInput]="playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlaylistItem()"
<app-viewer (pageChange)="pageChange.emit($event)" [playlistInput]="playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlaylistItem()"
[episode]="episode">
<div ngProjectAs="source-logo"><ng-content select="source-logo" /></div>
<ng-content />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class CommonReadComponent {
currentPlaylistItem = input<PlaylistItem | undefined>();

refreshData = output();
pageChange = output<{ total: number, current: number[] }>();

onRefreshData() {
this.refreshData.emit();
Expand Down
4 changes: 2 additions & 2 deletions src/app/@site-modules/@common-read/utils/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ export interface CompositionImage {
export interface CompositionPublisher {
id: string;
site: string;
avatar: CompositionImage;
avatar?: CompositionImage;
description: string
name: string;
links: Array<{
links?: Array<{
link: string,
title: string
}>
Expand Down
22 changes: 16 additions & 6 deletions src/app/@site-modules/@common-read/utils/read-base-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { BehaviorSubject, MonoTypeOperatorFunction, Observable, OperatorFunction
import { CompositionEpisode } from "./composition";
import { ActivatedRoute, ParamMap } from "@angular/router";
import { Title } from "@angular/platform-browser";
import { ChangeDetectorRef, OnDestroy, WritableSignal, inject, signal } from "@angular/core";
import { ChangeDetectorRef, OnDestroy, WritableSignal, inject, output, signal } from "@angular/core";
import { LangService } from "../../../shared/data-access/lang.service";
import { HistoryService } from "../../../history/data-access/history.service";
import { ViewerService } from "../../../shared/data-access";
import { PlaylistItem, PlaylistService, isPlaylist } from "../../../playlist/data-access/playlist.service";
import { MetaTagsService } from "../../../shared/data-access/meta-tags.service";
import { ViewerService } from "../../../viewer/services";

export abstract class ReadBaseComponent {
protected refresh$: BehaviorSubject<null> = new BehaviorSubject<null>(null);
Expand Down Expand Up @@ -109,21 +109,25 @@ export abstract class ReadBaseComponent {

})
}

site = '';
post_id = '';
protected tapSaveToHistory(site: string, post_id: string): MonoTypeOperatorFunction<CompositionEpisode> {
return tap(async (episode: CompositionEpisode) => {
if (episode) {
this.site = site;
this.post_id = post_id;
let e = structuredClone(episode);
const pages = e.images?.length ?? 0;
e.images = [];
await this.saveToHistory(site, post_id, episode.title, episode.images[0]?.src, e);
await this.saveToHistory(site, post_id, episode.title, episode.images[0]?.src, e, pages, 1);
}
})
}

public history: HistoryService = inject(HistoryService);

async saveToHistory(site: string, post_id: string, title: string, cover: string, episode: any) {
await this.history.addHistory(site, post_id, title, cover, episode);
async saveToHistory(site: string, post_id: string, title: string, cover: string, episode: any, pages: number = 0, page: number = 1) {
await this.history.addHistory(site, post_id, title, cover, episode, pages, page);
}

cdr = inject(ChangeDetectorRef)
Expand All @@ -143,4 +147,10 @@ export abstract class ReadBaseComponent {

})
}

protected onPageChange(event: { total: number, current: number[] }) {
const lastPageIndex = event.current[event.current.length - 1] - 1;
this.history.updatePage(this.site, this.post_id, lastPageIndex + 1);

}
}
2 changes: 1 addition & 1 deletion src/app/@site-modules/imgchest/imgchest-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { IMGCHEST_PATH } from '../../app-routing.module';
@Component({
imports: [CommonReadModule],
selector: 'app-imgchest-shell',
template: `<app-common-read [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()">
template: `<app-common-read (pageChange)="onPageChange($event)" [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()">
<source-copyright [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" />
<source-copyright-logo ngProjectAs="source-logo" [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" [sourceImageSrc]="sourceImageSrc()" />
</app-common-read>`
Expand Down
2 changes: 1 addition & 1 deletion src/app/@site-modules/imgur/imgur-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { IMGUR_PATH } from '../../app-routing.module';
@Component({
imports: [CommonReadModule],
selector: 'app-imgur-shell',
template: `<app-common-read [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()">
template: `<app-common-read (pageChange)="onPageChange($event)" [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()">
<source-copyright [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" />
<source-copyright-logo ngProjectAs="source-logo" [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" [sourceImageSrc]="sourceImageSrc()" />
</app-common-read>`
Expand Down
8 changes: 5 additions & 3 deletions src/app/@site-modules/mangadex/mangadex-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { MANGADEX_PATH } from '../../app-routing.module';
@Component({
imports: [CommonReadModule],
selector: 'app-mangadex-shell',
template: `<app-common-read [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()" >
template: `<app-common-read (pageChange)="onPageChange($event)" [episode$]="episode$" [error$]="error$" [loading$]="loading$" (refreshData)="refreshData()" [playlist]="playlistService.playlist()" [playlistLink]="playlistLink()" [currentPlaylistItem]="currentPlItem()" >
<source-copyright [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" />
<source-copyright-logo ngProjectAs="source-logo" [sourceName]="sourceName()" [sourceUrl]="sourceUrl()" [sourceImageSrc]="sourceImageSrc()" />
</app-common-read>`
Expand All @@ -33,11 +33,13 @@ export default class MangadexShellComponent extends ReadBaseComponent {
switchMap(ch => {
const imgs$ = this.mangadex.getChapterImages(id);
const manga$ = (ch.mangaId) ? this.mangadex.getManga(ch.mangaId) : of(null);
const group$ = (ch.publisher?.id !== null) ? this.mangadex.getScanlationGroup(ch.publisher?.id!) : of(null);

return forkJoin([imgs$, manga$]).pipe(
map(([imgs, manga]) => {
return forkJoin([imgs$, manga$, group$]).pipe(
map(([imgs, manga, group]) => {
ch.images = imgs;
ch.nsfw = manga?.nsfw ?? undefined;
ch.publisher = group ?? undefined;
return ch;
}),
this.catchError()
Expand Down
Loading
Loading