~nhanb/pytaku

37601a9230aa9620ebf8655e48d539e5cf8a1ac6 — Bùi Thành Nhân 2 years ago 6df7fc7 eink
e-ink proof of concept on Chapter route

Still no idea how to build a complete, usable UI for it though.
M src/pytaku/js-src/main.js => src/pytaku/js-src/main.js +2 -1
@@ 6,6 6,7 @@ import Follows from "./routes/follows.js";
import Search from "./routes/search.js";
import Title from "./routes/title.js";
import Chapter from "./routes/chapter.js";
import EInkChapter from "./routes/eink-chapter.js";
import Importer from "./routes/importer.js";

Auth.init().then(() => {


@@ 69,7 70,7 @@ Auth.init().then(() => {
      render: (vnode) =>
        m(
          Layout,
          m(Chapter, {
          m(EInkChapter, {
            site: vnode.attrs.site,
            titleId: vnode.attrs.titleId,
            chapterId: vnode.attrs.chapterId,

M src/pytaku/js-src/routes/chapter.js => src/pytaku/js-src/routes/chapter.js +8 -50
@@ 1,5 1,12 @@
import { Auth, ChapterModel } from "../models.js";
import { LoadingMessage, fullChapterName, Button } from "../utils.js";
import {
  LoadingMessage,
  FallbackableImg,
  fullChapterName,
  Button,
  RetryImgButton,
  ImgStatus,
} from "../utils.js";

const KEYCODE_PLUS = 43;
const KEYCODE_MINUS = 45;


@@ 14,55 21,6 @@ const PendingPlaceholder = {
  view: () => m("h2", [m("i.icon.icon-loader")]),
};

const RetryImgButton = {
  view: (vnode) => {
    return m(Button, {
      text: "Errored. Try again?",
      color: "red",
      onclick: (ev) => {
        const { page } = vnode.attrs;
        page.status = ImgStatus.LOADING;
        // Cheat: append to src so the element's key is
        // different, forcing mithril to redraw.
        // Chose `?` here because it will just be stripped by
        // flask's path parser.
        page.src = page.src.endsWith("?")
          ? page.src.slice(0, -1)
          : page.src + "?";
      },
    });
  },
};

const ImgStatus = {
  LOADING: "loading",
  SUCCEEDED: "succeeded",
  FAILED: "failed",
};

function FallbackableImg(initialVNode) {
  let currentSrc;
  return {
    oninit: (vnode) => {
      currentSrc = vnode.attrs.src;
    },
    view: (vnode) => {
      return m("img", {
        src: currentSrc,
        style: vnode.attrs.style,
        onload: vnode.attrs.onload,
        onerror: (ev) => {
          if (currentSrc === vnode.attrs.src && vnode.attrs.altsrc !== null) {
            currentSrc = vnode.attrs.altsrc;
          } else {
            vnode.attrs.onerror(ev);
          }
        },
      });
    },
  };
}

function Chapter(initialVNode) {
  let isLoading = false;
  let isMarkingLastChapterAsRead = false;

A src/pytaku/js-src/routes/eink-chapter.js => src/pytaku/js-src/routes/eink-chapter.js +176 -0
@@ 0,0 1,176 @@
import { Auth, ChapterModel } from "../models.js";
import {
  fullChapterName,
  Button,
  FallbackableImg,
  RetryImgButton,
  ImgStatus,
} from "../utils.js";

const LoadingPlaceholder = {
  view: () => m("h2", [m("i.icon.icon-loader")]),
};

function EInkChapter(initialVNode) {
  let isLoading = false;
  let isMarkingLastChapterAsRead = false;
  let chapter = {};
  let pendingPages = [];
  let loadedPages = [];
  let site, titleId; // these are written on init
  let currentPageIndex = 0;

  let eInkRefreshTimeoutId = null;
  let isInverted = false;

  function loadNextPage() {
    if (pendingPages.length > 0) {
      let [src, altsrc] = pendingPages.splice(0, 1)[0];
      loadedPages.push({
        status: ImgStatus.LOADING,
        src,
        altsrc,
      });
    }
  }

  return {
    oninit: (vnode) => {
      document.title = "Manga chapter";
      site = vnode.attrs.site;
      titleId = vnode.attrs.titleId;

      isLoading = true;
      m.redraw();

      ChapterModel.get({
        site: vnode.attrs.site,
        titleId: vnode.attrs.titleId,
        chapterId: vnode.attrs.chapterId,
      })
        .then((resp) => {
          chapter = resp;
          document.title = fullChapterName(chapter);

          // "zip" pages & pages_alt into pendingPages
          if (chapter.pages_alt.length > 0) {
            pendingPages = chapter.pages.map((page, i) => {
              return [page, chapter.pages_alt[i]];
            });
          } else {
            pendingPages = chapter.pages.map((page) => {
              return [page, null];
            });
          }

          loadNextPage();
          loadNextPage();
          loadNextPage();
        })
        .finally(() => {
          isLoading = false;
        });
    },

    view: (vnode) => {
      if (isLoading) {
        return m(
          "div.chapter.content",
          m("h2.blink", [m("i.icon.icon-loader.spin"), " loading..."])
        );
      }

      const { site, titleId } = vnode.attrs;
      const prev = chapter.prev_chapter;
      const next = chapter.next_chapter;

      return m(
        ".e-ink-chapter",
        {
          style: {
            position: "absolute",
            backgroundColor: "white",
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            zIndex: 1,
            textAlign: "center",
          },
        },
        [
          loadedPages.map((page, pageIndex) =>
            m("div", { key: page.src }, [
              m(FallbackableImg, {
                src: page.src,
                altsrc: page.altsrc,
                style: {
                  display:
                    page.status === ImgStatus.SUCCEEDED &&
                    pageIndex === currentPageIndex
                      ? "block"
                      : "none",
                  filter: isInverted ? "invert(1)" : null,
                  position: "absolute",
                  top: 0,
                  left: 0,
                  right: 0,
                  margin: "auto",
                  maxHeight: "100%",
                  zIndex: 2,
                },
                onload: (ev) => {
                  page.status = ImgStatus.SUCCEEDED;
                  loadNextPage();
                },
                onerror: (ev) => {
                  page.status = ImgStatus.FAILED;
                  loadNextPage();
                },
              }),
              page.status === ImgStatus.LOADING &&
              pageIndex === currentPageIndex
                ? m(LoadingPlaceholder)
                : null,
              page.status === ImgStatus.FAILED && pageIndex === currentPageIndex
                ? m(
                    "div",
                    { style: { "margin-bottom": ".5rem" } },
                    m(RetryImgButton, { page })
                  )
                : null,
            ])
          ),
          m(".e-ink-chapter--prev-page", {
            onclick: (ev) => {
              if (currentPageIndex > 0) currentPageIndex--;
              if (eInkRefreshTimeoutId !== null) {
                clearTimeout(eInkRefreshTimeoutId);
              }
              isInverted = true;
              eInkRefreshTimeoutId = setTimeout(() => {
                isInverted = false;
                m.redraw();
              }, 450);
            },
          }),
          m(".e-ink-chapter--next-page", {
            onclick: (ev) => {
              if (currentPageIndex < loadedPages.length - 1) currentPageIndex++;
              if (eInkRefreshTimeoutId !== null) {
                clearTimeout(eInkRefreshTimeoutId);
              }
              isInverted = true;
              eInkRefreshTimeoutId = setTimeout(() => {
                isInverted = false;
                m.redraw();
              }, 450);
            },
          }),
        ]
      );
    },
  };
}

export default EInkChapter;

M src/pytaku/js-src/utils.js => src/pytaku/js-src/utils.js +59 -1
@@ 57,4 57,62 @@ function fullChapterName(chapter) {
  return result;
}

export { LoadingMessage, Button, Chapter, truncate, fullChapterName };
function FallbackableImg(initialVNode) {
  let currentSrc;
  return {
    oninit: (vnode) => {
      currentSrc = vnode.attrs.src;
    },
    view: (vnode) => {
      return m("img", {
        src: currentSrc,
        style: vnode.attrs.style,
        onload: vnode.attrs.onload,
        onerror: (ev) => {
          if (currentSrc === vnode.attrs.src && vnode.attrs.altsrc !== null) {
            currentSrc = vnode.attrs.altsrc;
          } else {
            vnode.attrs.onerror(ev);
          }
        },
      });
    },
  };
}

const RetryImgButton = {
  view: (vnode) => {
    return m(Button, {
      text: "Errored. Try again?",
      color: "red",
      onclick: (ev) => {
        const { page } = vnode.attrs;
        page.status = ImgStatus.LOADING;
        // Cheat: append to src so the element's key is
        // different, forcing mithril to redraw.
        // Chose `?` here because it will just be stripped by
        // flask's path parser.
        page.src = page.src.endsWith("?")
          ? page.src.slice(0, -1)
          : page.src + "?";
      },
    });
  },
};

const ImgStatus = {
  LOADING: "loading",
  SUCCEEDED: "succeeded",
  FAILED: "failed",
};

export {
  LoadingMessage,
  Button,
  Chapter,
  truncate,
  fullChapterName,
  FallbackableImg,
  RetryImgButton,
  ImgStatus,
};

M src/pytaku/static/spa.css => src/pytaku/static/spa.css +18 -0
@@ 434,6 434,24 @@ footer {
  }
}

/* E-ink Chapter route */

.e-ink-chapter--prev-page,
.e-ink-chapter--next-page {
  position: fixed;
  z-index: 3;
  top: 0;
  bottom: 0;
}
.e-ink-chapter--prev-page {
  left: 0;
  right: 50%;
}
.e-ink-chapter--next-page {
  right: 0;
  left: 50%;
}

/* Components defined in utils */
.utils--chapter {
  margin-bottom: 0.5rem;