Ebook and comic reader #14

This commit is contained in:
advplyr 2021-10-17 20:20:00 -05:00
parent 3234e7ef06
commit 5a025b3a03
21 changed files with 2139 additions and 14 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 22
versionName "0.9.6-beta"
versionCode 23
versionName "0.9.7-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,5 +1,9 @@
@import "./fonts.css";
.box-shadow-sm {
box-shadow: 0px 3px 6px #11111170;
}
.box-shadow-md {
box-shadow: 2px 8px 6px #111111aa;
}

506
assets/ebooks/basic.js Normal file
View file

@ -0,0 +1,506 @@
/*
Calibres stylesheet
*/
export default `
@charset "UTF-8";
/*
Calibre styles
*/
.arabic {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.attribution {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
margin: 0.3em 0
}
.big {
font-size: 1.375em;
line-height: 1.2
}
.big1 {
font-size: 1em
}
.block {
display: block;
text-align: justify;
margin: 1em 1em 2em
}
.block1 {
display: block;
text-align: justify;
margin: 1em 4em
}
.block2 {
display: block;
text-align: justify;
margin: 1em 1em 1em 2em
}
.bullet {
display: block;
list-style-type: disc;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: disc
}
.calibre {
background-color: #000007;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre1 {
display: block
}
.calibre2 {
height: auto;
width: auto
}
.calibre3:not(strong) {
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
margin: 0 5pt
}
.calibre4 {
font-weight: bold
}
.calibre5 {
font-style: italic
}
.calibre6 {
background-color: #FFF;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre7 {
display: list-item
}
.calibre8 {
font-size: 1em;
line-height: 1.2;
vertical-align: super
}
.calibre9 {
border-collapse: separate;
border-spacing: 2px;
display: table;
margin-bottom: 0;
margin-top: 0;
text-indent: 0
}
.calibre10 {
display: table-row;
vertical-align: middle
}
.calibre11 {
display: table-cell;
text-align: right;
vertical-align: inherit;
padding: 1px
}
.calibre12 {
display: table-cell;
text-align: left;
vertical-align: inherit;
padding: 1px
}
.calibre13 {
height: 1em;
width: auto
}
.calibre14 {
font-size: 0.88889em;
line-height: 1.2;
vertical-align: super
}
.calibre15 {
font-size: 1em;
line-height: 1.2;
vertical-align: sub
}
.calibre16 {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em
}
.calibre17 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 0.83em 0
}
.center {
display: block;
text-align: center;
margin: 1em 0
}
.center1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: -2em 0 3em
}
.center2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 1em
}
.center3 {
display: block;
text-align: center;
margin: -1em 0 1em
}
.center4 {
display: block;
text-align: center;
text-indent: 3%;
margin: 1em 0
}
.chapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 1em
}
.chapter1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 0.5em;
margin-right: 0.5em;
margin-top: 2em
}
.chapter2 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 3em
}
.copyright {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em;
text-align: center
}
.dedication {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em
}
.dropcaps {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: -0.05em;
padding-top: 1px
}
.dropcaps1 {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: 0.15em;
padding-top: 1px
}
.extract {
display: block;
text-align: justify;
margin: 2em 0 0.3em
}
.extract1 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 2em 0 0.3em
}
.extract2 {
display: block;
text-align: justify;
margin: 1em 0 0.3em
}
.footnote {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 1em;
line-height: 1.2;
margin-top: 2 em
}
.footnote1 {
display: block;
text-align: justify;
margin: 0.3em 0 0.3em 2
}
.footnote2 {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 2 em
}
.hanging {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1.5em
}
.hanging2 {
display: block;
font-size: 1em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging3 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 1em;
margin: 0.1em 0 0.3em 1em
}
.hanging4 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 0.1em;
margin: 0.1em 0 0.3em 1em
}
a.hlink {
text-decoration: none
}
.indent {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0
}
.line {
border-top: currentColor solid 1px;
border-bottom: currentColor solid 1px
}
.loweralpha {
display: block;
list-style-type: lower-alpha;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none {
display: block;
list-style-type: none;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none1 {
display: block;
list-style-type: none;
margin-bottom: 0;
margin-right: 0;
margin-top: 0;
text-align: justify
}
.nonindent {
display: block;
text-align: justify;
margin: 0.3em 0
}
.nonindent1 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 0.1em
}
.nonindent2 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em -0.5em
}
.nonindent3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0
}
.part {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 4em 0 1em
}
.preface {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 2em;
margin-right: 2em;
text-align: justify
}
.pubhlink {
color: green;
text-decoration: none
}
.right {
display: block;
text-align: right;
margin: 0.3em 0
}
.section {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 0.5em
}
.section1 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em
}
.section2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em 1em
}
.small {
font-size: 0.66667em
}
.small1 {
font-size: 0.75em
}
.subchapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 1em 0
}
.textbox {
background-color: #E4E4E4;
display: block;
line-height: 1.5em;
margin-bottom: 2em;
margin-top: 2em;
text-align: justify;
border-top: currentColor double 2px;
border-bottom: currentColor double 2px
}
.textbox1 {
display: block;
text-align: justify;
margin: 0.3em 0.5em 0.3em 0.8em
}
.textbox2 {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0.5em
}
.textbox3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0.5em 0.3em 0.8em
}
.titlepage {
display: block;
margin-left: -0.4em;
margin-top: 1.2em
}
.toc {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: center
}
.toc1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 0.67em 0 3em
}
.underline {
text-decoration: underline
}
`

248
assets/ebooks/htmlParser.js Normal file
View file

@ -0,0 +1,248 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
export const isTitle = (
line,
isContainDI = false,
isContainChapter = false,
isContainCHAPTER = false
) => {
return (
line.length < 30 &&
line.indexOf("[") === -1 &&
line.indexOf("(") === -1 &&
(line.startsWith("CHAPTER") ||
line.startsWith("Chapter") ||
line.startsWith("序章") ||
line.startsWith("前言") ||
line.startsWith("声明") ||
line.startsWith("聲明") ||
line.startsWith("写在前面的话") ||
line.startsWith("后记") ||
line.startsWith("楔子") ||
line.startsWith("后序") ||
line.startsWith("寫在前面的話") ||
line.startsWith("後記") ||
line.startsWith("後序") ||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line
) ||
(line.startsWith("第") && startWithDI(line)) ||
(line.startsWith("卷") && startWithJUAN(line)) ||
startWithRomanNum(line) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("第") > -1 &&
(line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === "、" ||
line[line.indexOf("第") - 1] === "" ||
line[line.indexOf("第") - 1] === ":") &&
startWithDI(line.substr(line.indexOf("第")))) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("、") &&
startWithNumAndPause(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("") &&
startWithNumAndColon(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(":") &&
startWithNumAndColon(line)))
);
};
const startWithDI = (line) => {
let keywords = [
"章",
"节",
"回",
"節",
"卷",
"部",
"輯",
"辑",
"話",
"集",
"话",
"篇",
];
let flag = false;
for (let i = 0; i < keywords.length; i++) {
if (
(line.indexOf(keywords[i]) > -1 &&
(line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === "、" ||
line[line.indexOf(keywords[i]) + 1] === "" ||
line[line.indexOf(keywords[i]) + 1] === ":")) ||
!line[line.indexOf(keywords[i]) + 1]
) {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(keywords[i])).trim()
) ||
/^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())
) {
flag = true;
}
if (flag) break;
}
}
return flag;
};
const startWithJUAN = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1)
) ||
/^\d+$/.test(line.substring(1))
)
return true;
return false;
};
const startWithRomanNum = (line) => {
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf("."))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.trim()
)
)
return true;
return false;
};
const startWithNumAndSpace = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
return false;
};
const startWithNumAndColon = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(":"))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(""))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("")))) return true;
return false;
};
const startWithNumAndPause = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf("、"))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true;
return false;
};
class HtmlParser {
bookDoc;
contentList;
contentTitleList;
constructor(bookDoc) {
this.bookDoc = bookDoc;
this.contentList = [];
this.contentTitleList = [];
this.getContent(bookDoc);
}
getContent(bookDoc) {
this.contentList = Array.from(
bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font")
).filter((item, index) => {
return isTitle(item.innerText.trim());
});
for (let i = 0; i < this.contentList.length; i++) {
let random = Math.floor(Math.random() * 900000) + 100000;
this.contentTitleList.push({
label: this.contentList[i].innerText,
id: "title" + random,
href: "#title" + random,
subitems: [],
});
}
for (let i = 0; i < this.contentList.length; i++) {
this.contentList[i].id = this.contentTitleList[i].id;
}
}
getAnchoredDoc() {
return this.bookDoc;
}
getContentList() {
return this.contentTitleList.filter((item, index) => {
if (index > 0) {
return item.label !== this.contentTitleList[index - 1].label;
} else {
return true;
}
});
}
}
export default HtmlParser;

450
assets/ebooks/mobi.js Normal file
View file

@ -0,0 +1,450 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
function ab2str(buf) {
if (buf instanceof ArrayBuffer) {
buf = new Uint8Array(buf);
}
return new TextDecoder("utf-8").decode(buf);
}
var domParser = new DOMParser();
class Buffer {
capacity;
fragment_list;
imageArray;
cur_fragment;
constructor(capacity) {
this.capacity = capacity;
this.fragment_list = [];
this.imageArray = [];
this.cur_fragment = new Fragment(capacity);
this.fragment_list.push(this.cur_fragment);
}
write(byte) {
var result = this.cur_fragment.write(byte);
if (!result) {
this.cur_fragment = new Fragment(this.capacity);
this.fragment_list.push(this.cur_fragment);
this.cur_fragment.write(byte);
}
}
get(idx) {
var fi = 0;
while (fi < this.fragment_list.length) {
var frag = this.fragment_list[fi];
if (idx < frag.size) {
return frag.get(idx);
}
idx -= frag.size;
fi += 1;
}
return null;
}
size() {
var s = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
s += this.fragment_list[i].size;
}
return s;
}
shrink() {
var total_buffer = new Uint8Array(this.size());
var offset = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
var frag = this.fragment_list[i];
if (frag.full()) {
total_buffer.set(frag.buffer, offset);
} else {
total_buffer.set(frag.buffer.slice(0, frag.size), offset);
}
offset += frag.size;
}
return total_buffer;
}
}
var copagesne_uint8array = function (buffers) {
var total_size = 0;
for (let i = 0; i < buffers.length; i++) {
var buffer = buffers[i];
total_size += buffer.length;
}
var total_buffer = new Uint8Array(total_size);
var offset = 0;
for (let i = 0; i < buffers.length; i++) {
buffer = buffers[i];
total_buffer.set(buffer, offset);
offset += buffer.length;
}
return total_buffer;
};
class Fragment {
buffer;
capacity;
size;
constructor(capacity) {
this.buffer = new Uint8Array(capacity);
this.capacity = capacity;
this.size = 0;
}
write(byte) {
if (this.size >= this.capacity) {
return false;
}
this.buffer[this.size] = byte;
this.size += 1;
return true;
}
full() {
return this.size === this.capacity;
}
get(idx) {
return this.buffer[idx];
}
}
var uncompression_lz77 = function (data) {
var length = data.length;
var offset = 0; // Current offset into data
var buffer = new Buffer(data.length);
while (offset < length) {
var char = data[offset];
offset += 1;
if (char === 0) {
buffer.write(char);
} else if (char <= 8) {
for (var i = offset; i < offset + char; i++) {
buffer.write(data[i]);
}
offset += char;
} else if (char <= 0x7f) {
buffer.write(char);
} else if (char <= 0xbf) {
var next = data[offset];
offset += 1;
var distance = (((char << 8) | next) >> 3) & 0x7ff;
var lz_length = (next & 0x7) + 3;
var buffer_size = buffer.size();
for (let i = 0; i < lz_length; i++) {
buffer.write(buffer.get(buffer_size - distance));
buffer_size += 1;
}
} else {
buffer.write(32);
buffer.write(char ^ 0x80);
}
}
return buffer;
};
class MobiFile {
view;
buffer;
offset;
header;
palm_header;
mobi_header;
reclist;
constructor(data) {
this.view = new DataView(data);
this.buffer = this.view.buffer;
this.offset = 0;
this.header = null;
}
parse() { }
getUint8() {
var v = this.view.getUint8(this.offset);
this.offset += 1;
return v;
}
getUint16() {
var v = this.view.getUint16(this.offset);
this.offset += 2;
return v;
}
getUint32() {
var v = this.view.getUint32(this.offset);
this.offset += 4;
return v;
}
getStr(size) {
var v = ab2str(this.buffer.slice(this.offset, this.offset + size));
this.offset += size;
return v;
}
skip(size) {
this.offset += size;
}
setoffset(_of) {
this.offset = _of;
}
get_record_extrasize(data, flags) {
var pos = data.length - 1;
var extra = 0;
for (var i = 15; i > 0; i--) {
if (flags & (1 << i)) {
var res = this.buffer_get_varlen(data, pos);
var size = res[0];
var l = res[1];
pos = res[2];
pos -= size - l;
extra += size;
}
}
if (flags & 1) {
var a = data[pos];
extra += (a & 0x3) + 1;
}
return extra;
}
// data should be uint8array
buffer_get_varlen(data, pos) {
var l = 0;
var size = 0;
var byte_count = 0;
var mask = 0x7f;
var stop_flag = 0x80;
var shift = 0;
for (var i = 0; ; i++) {
var byte = data[pos];
size |= (byte & mask) << shift;
shift += 7;
l += 1;
byte_count += 1;
pos -= 1;
var to_stop = byte & stop_flag;
if (byte_count >= 4 || to_stop > 0) {
break;
}
}
return [size, l, pos];
}
// 读出文本内容
read_text() {
var text_end = this.palm_header.record_count;
var buffers = [];
for (var i = 1; i <= text_end; i++) {
buffers.push(this.read_text_record(i));
}
var all = copagesne_uint8array(buffers);
return ab2str(all);
}
read_text_record(i) {
var flags = this.mobi_header.extra_flags;
var begin = this.reclist[i].offset;
var end = this.reclist[i + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
var ex = this.get_record_extrasize(data, flags);
data = new Uint8Array(this.buffer.slice(begin, end - ex));
if (this.palm_header.compression === 2) {
var buffer = uncompression_lz77(data);
return buffer.shrink();
} else {
return data;
}
}
// 从buffer中读出image
read_image(idx) {
var first_image_idx = this.mobi_header.first_image_idx;
var begin = this.reclist[first_image_idx + idx].offset;
var end = this.reclist[first_image_idx + idx + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
return new Blob([data.buffer]);
}
load() {
this.header = this.load_pdbheader();
this.reclist = this.load_reclist();
this.load_record0();
}
load_pdbheader() {
var header = {};
header.name = this.getStr(32);
header.attr = this.getUint16();
header.version = this.getUint16();
header.ctime = this.getUint32();
header.mtime = this.getUint32();
header.btime = this.getUint32();
header.mod_num = this.getUint32();
header.appinfo_offset = this.getUint32();
header.sortinfo_offset = this.getUint32();
header.type = this.getStr(4);
header.creator = this.getStr(4);
header.uid = this.getUint32();
header.next_rec = this.getUint32();
header.record_num = this.getUint16();
return header;
}
load_reclist() {
var reclist = [];
for (var i = 0; i < this.header.record_num; i++) {
var record = {};
record.offset = this.getUint32();
// TODO(zz) change
record.attr = this.getUint32();
reclist.push(record);
}
return reclist;
}
load_record0() {
this.palm_header = this.load_record0_header();
this.mobi_header = this.load_mobi_header();
}
load_record0_header() {
var p_header = {};
var first_record = this.reclist[0];
this.setoffset(first_record.offset);
p_header.compression = this.getUint16();
this.skip(2);
p_header.text_length = this.getUint32();
p_header.record_count = this.getUint16();
p_header.record_size = this.getUint16();
p_header.encryption_type = this.getUint16();
this.skip(2);
return p_header;
}
load_mobi_header() {
var mobi_header = {};
var start_offset = this.offset;
mobi_header.identifier = this.getUint32();
mobi_header.header_length = this.getUint32();
mobi_header.mobi_type = this.getUint32();
mobi_header.text_encoding = this.getUint32();
mobi_header.uid = this.getUint32();
mobi_header.generator_version = this.getUint32();
this.skip(40);
mobi_header.first_nonbook_index = this.getUint32();
mobi_header.full_name_offset = this.getUint32();
mobi_header.full_name_length = this.getUint32();
mobi_header.language = this.getUint32();
mobi_header.input_language = this.getUint32();
mobi_header.output_language = this.getUint32();
mobi_header.min_version = this.getUint32();
mobi_header.first_image_idx = this.getUint32();
mobi_header.huff_rec_index = this.getUint32();
mobi_header.huff_rec_count = this.getUint32();
mobi_header.datp_rec_index = this.getUint32();
mobi_header.datp_rec_count = this.getUint32();
mobi_header.exth_flags = this.getUint32();
this.skip(36);
mobi_header.drm_offset = this.getUint32();
mobi_header.drm_count = this.getUint32();
mobi_header.drm_size = this.getUint32();
mobi_header.drm_flags = this.getUint32();
this.skip(8);
// TODO (zz) fdst_index
this.skip(4);
this.skip(46);
mobi_header.extra_flags = this.getUint16();
this.setoffset(start_offset + mobi_header.header_length);
return mobi_header;
}
load_exth_header() {
// TODO
return {};
}
extractContent(s) {
var span = document.createElement("span");
span.innerHTML = s;
return span.textContent || span.innerText;
}
render(isElectron = false) {
return new Promise((resolve, reject) => {
this.load();
var content = this.read_text();
var bookDoc = domParser.parseFromString(content, "text/html")
.documentElement;
let lines = Array.from(
bookDoc.querySelectorAll("p,b,font,h3,h2,h1")
);
let parseContent = [];
for (let i = 0, len = lines.length; i < len - 1; i++) {
lines[i].innerText &&
lines[i].innerText !== parseContent[parseContent.length - 1] &&
parseContent.push(lines[i].innerText);
let imgDoms = lines[i].getElementsByTagName("img");
if (imgDoms.length > 0) {
for (let i = 0; i < imgDoms.length; i++) {
parseContent.push("#image");
}
}
}
const handleImage = async () => {
var imgDoms = bookDoc.getElementsByTagName("img");
parseContent.push("~image");
for (let i = 0; i < imgDoms.length; i++) {
const src = await this.render_image(imgDoms, i);
parseContent.push(
src + " " + imgDoms[i].width + " " + imgDoms[i].height
);
}
if (imgDoms.length > 200 || !isElectron) {
resolve(bookDoc);
} else {
resolve(parseContent.join("\n \n"));
}
};
handleImage();
});
}
render_image = (imgDoms, i) => {
return new Promise((resolve, reject) => {
var imgDom = imgDoms[i];
var idx = +imgDom.getAttribute("recindex");
var blob = this.read_image(idx - 1);
var imgReader = new FileReader();
imgReader.onload = (e) => {
imgDom.src = e.target?.result;
resolve(e.target?.result);
};
imgReader.onerror = function (err) {
reject(err);
};
imgReader.readAsDataURL(blob);
});
};
}
export default MobiFile;

View file

@ -24,6 +24,9 @@
.material-icons.text-icon {
font-size: 1.15rem;
}
.material-icons.text-lg {
font-size: 1.25rem;
}
.material-icons.text-base {
font-size: 1rem;
}

View file

@ -11,14 +11,17 @@
<!-- <button class="mx-1" @click="editAudiobook(ab)">
<span class="material-icons text-icon pb-px">edit</span>
</button> -->
<button v-if="!isPlaying" class="mx-1 rounded-full w-6 h-6" @click="playAudiobook">
<button v-if="showRead" class="mx-1 rounded-full w-6 h-6" @click="readBook">
<span class="material-icons">auto_stories</span>
</button>
<button v-if="showPlay" class="mx-1 rounded-full w-6 h-6" @click="playAudiobook">
<span class="material-icons">play_arrow</span>
</button>
</div>
</div>
<p v-if="audiobook.book.subtitle" class="text-gray-200 leading-6 truncate" style="font-size: 0.9rem">{{ audiobook.book.subtitle }}</p>
<p class="text-sm text-gray-200">by {{ audiobook.book.author }}</p>
<div class="flex items-center py-1">
<div v-if="numTracks" class="flex items-center py-1">
<p class="text-xs text-gray-300">{{ $elapsedPretty(audiobook.duration) }}</p>
<span class="px-3 text-xs text-gray-300"></span>
<p class="text-xs text-gray-300 font-mono">{{ $bytesPretty(audiobook.size, 0) }}</p>
@ -38,6 +41,9 @@
<div v-if="isPlaying" class="w-min my-1 mx-1">
<div class="bg-info bg-opacity-70 text-sm px-2 py-px rounded-full whitespace-nowrap">{{ isStreaming ? 'Streaming' : 'Playing' }}</div>
</div>
<div v-if="hasEbook" class="w-min my-1 mx-1">
<div class="bg-bg bg-opacity-70 text-sm px-2 py-px rounded-full whitespace-nowrap">{{ ebookFormat }}</div>
</div>
</div>
</div>
</div>
@ -96,12 +102,34 @@ export default {
isPlaying() {
return this.$store.getters['isAudiobookPlaying'](this.audiobookId)
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
numTracks() {
if (this.audiobook.tracks) return this.audiobook.tracks.length
return this.audiobook.numTracks || 0
},
showPlay() {
return !this.isPlaying && !this.isMissing && !this.isIncomplete && this.numTracks
},
showRead() {
return this.hasEbook && this.ebookFormat !== '.pdf'
},
hasEbook() {
return this.audiobook.numEbooks
},
ebookFormat() {
if (!this.audiobook || !this.audiobook.ebooks || !this.audiobook.ebooks.length) return null
return this.audiobook.ebooks[0].ext.substr(1)
}
},
methods: {
readBook() {
this.$store.commit('openReader', this.audiobook)
},
playAudiobook() {
if (this.isPlaying) {
return

View file

@ -0,0 +1,226 @@
<template>
<div id="comic-reader" class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52" style="top: 72px">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-20 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" style="top: 72px">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
>: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-8 right-36 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-lg">more</span>
</div>
<div class="absolute top-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 92px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-lg">menu</span>
</div>
<div class="absolute top-8 right-4 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
</div>
</template>
<script>
import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
})
export default {
props: {
url: String
},
data() {
return {
loading: false,
pages: null,
filesObject: null,
mainImg: null,
page: 0,
numPages: 0,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null
}
},
watch: {
url: {
immediate: true,
handler() {
this.extract()
}
}
},
computed: {
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
},
canGoPrev() {
return this.page > 0
}
},
methods: {
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
},
next() {
if (!this.canGoNext) return
this.setPage(this.page + 1)
},
prev() {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
return
}
var filename = this.pages[index]
this.page = index
return this.extractFile(filename)
},
setLoadTimeout() {
this.loadTimeout = setTimeout(() => {
this.loading = true
}, 150)
},
extractFile(filename) {
return new Promise(async (resolve) => {
this.setLoadTimeout()
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.mainImg = e.target.result
this.loading = false
resolve()
}
reader.onerror = (e) => {
console.error(e)
this.$toast.error('Read page file failed')
this.loading = false
resolve()
}
reader.readAsDataURL(file)
clearTimeout(this.loadTimeout)
})
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject()
var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames)
var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')
if (xmlFile) await this.extractXmlFile(xmlFile)
this.numPages = this.pages.length
if (this.pages.length) {
this.loading = false
await this.setPage(0)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
this.loading = false
}
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.comicMetadata = this.$xmlToJson(e.target.result)
console.log('Metadata', this.comicMetadata)
}
reader.onerror = (e) => {
console.error(e)
}
reader.readAsText(file)
} catch (error) {
console.error(error)
}
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,
filename
}
} else {
return {
index: Number(numbersinpath[numbersinpath.length - 1]),
filename
}
}
},
parseFilenames(filenames) {
const acceptableImages = ['.jpeg', '.jpg', '.png']
var imageFiles = filenames.filter((f) => {
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
})
var imageFileObjs = imageFiles.map((img) => {
return this.parseImageFilename(img)
})
var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)
var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)
var noNumImages = imageFileObjs.filter((i) => i.index < 0)
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages
}
},
mounted() {},
beforeDestroy() {}
}
</script>
<style scoped>
#comic-reader {
height: calc(100vh - 32px);
}
.pagemenu {
max-height: calc(100vh - 80px);
}
.comicimg {
height: 100%;
margin: auto;
}
.comicwrapper {
width: 100vw;
height: 100%;
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<div id="epub-frame" class="w-full">
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center">
<p class="text-xs">epub</p>
<div class="flex-grow" />
<p class="text-sm">{{ progress }}%</p>
</div>
</div>
</template>
<script>
import ePub from 'epubjs'
export default {
props: {
url: String
},
data() {
return {
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
}
},
computed: {},
methods: {
prev() {
if (this.rendition) {
this.rendition.prev()
}
},
next() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
initEpub() {
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', {
width: window.innerWidth,
height: window.innerHeight - 64,
snap: true,
manager: 'continuous',
flow: 'paginated'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
// this.author = metadata.creator
// this.title = metadata.title
})
// const spine_get = book.spine.get.bind(book.spine)
// book.spine.get = function (target) {
// var t = spine_get(target)
// console.log(t, target)
// // while (t == null && target.includes('#')) {
// // target = target.split('#')[0]
// // t = spine_get(target)
// // }
// return t
// }
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
}
},
mounted() {
this.initEpub()
}
}
</script>
<style>
#epub-frame {
height: calc(100% - 32px);
max-height: calc(100% - 32px);
overflow: hidden;
}
</style>

View file

@ -0,0 +1,120 @@
<template>
<div class="ebook-viewer w-full h-full">
<div class="absolute overflow-y-scroll left-0 right-0 w-full max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" class="w-screen"> Loading </iframe>
</div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20">
<p class="text-xs">mobi</p>
<div class="flex-grow" />
</div>
</div>
</template>
<script>
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
},
data() {
return {}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
if (!iframe) return
let doc = iframe.contentDocument
if (!doc) return
let style = doc.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
doc.head.appendChild(style)
},
handleIFrameHeight(iFrame) {
const isElement = (obj) => !!(obj && obj.nodeType === 1)
var body = iFrame.contentWindow.document.body,
html = iFrame.contentWindow.document.documentElement
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
setTimeout(() => {
let lastchild = body.lastElementChild
let lastEle = body.lastChild
let itemAs = body.querySelectorAll('a')
let itemPs = body.querySelectorAll('p')
let lastItemA = itemAs[itemAs.length - 1]
let lastItemP = itemPs[itemPs.length - 1]
let lastItem
if (isElement(lastItemA) && isElement(lastItemP)) {
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
lastItem = lastItemA
} else {
lastItem = lastItemP
}
}
if (!lastchild && !lastItem && !lastEle) return
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
let nodeHeight = 0
if (lastEle.nodeType === 3 && document.createRange) {
let range = document.createRange()
range.selectNodeContents(lastEle)
if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect()
if (rect) {
nodeHeight = rect.bottom - rect.top
}
}
}
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
}, 500)
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
var file_content = event.target.result
let mobiFile = new MobiParser(file_content)
let content = await mobiFile.render()
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
var anchoredDoc = htmlParser.getAnchoredDoc()
let iFrame = document.getElementsByTagName('iframe')[0]
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
// Add css
let style = iFrame.contentDocument.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
iFrame.contentDocument.head.appendChild(style)
this.handleIFrameHeight(iFrame)
}
reader.readAsArrayBuffer(buff)
}
},
mounted() {
this.initMobi()
}
}
</script>
<style>
.ebook-viewer {
height: calc(100% - 32px);
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8">
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
<p class="w-5/6 truncate">{{ title }}</p>
<div class="flex-grow" />
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
</div>
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" />
</div>
</template>
<script>
export default {
data() {
return {
ebookType: null,
ebookUrl: null,
touchstartX: 0,
touchendX: 0
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.showReader
},
set(val) {
this.$store.commit('setShowReader', val)
}
},
title() {
return this.selectedBook ? this.selectedBook.book.title : null
},
selectedBook() {
return this.$store.state.selectedBook
},
readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
ebook() {
if (!this.selectedBook || !this.selectedBook.ebooks || !this.selectedBook.ebooks.length) return null
return this.selectedBook.ebooks[0]
},
ebookPath() {
return this.ebook ? this.ebook.path : null
},
folderId() {
return this.selectedBook ? this.selectedBook.folderId : null
},
libraryId() {
return this.selectedBook ? this.selectedBook.libraryId : null
},
ebookRelPath() {
return `/ebook/${this.libraryId}/${this.folderId}/${this.ebookPath}`
}
},
methods: {
init() {
if (!this.ebook) {
console.error('No ebook for book', this.selectedBook)
return
}
if (this.ebook.ext === '.epub') {
this.ebookType = 'epub'
} else if (this.ebook.ext === '.mobi' || this.ebook.ext === '.azw3') {
this.ebookType = 'mobi'
} else if (this.ebook.ext === '.cbr' || this.ebook.ext === '.cbz') {
this.ebookType = 'comic'
}
var serverUrl = this.$store.state.serverUrl
this.ebookUrl = `${serverUrl}${this.ebookRelPath}`
},
next() {
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
this.$refs.readerComponent.next()
}
},
prev() {
if (this.$refs.readerComponent && this.$refs.readerComponent.prev) {
this.$refs.readerComponent.prev()
}
},
handleGesture() {
if (this.touchendX < this.touchstartX) {
console.log('swiped left')
this.next()
}
if (this.touchendX > this.touchstartX) {
console.log('swiped right')
this.prev()
}
},
touchstart(e) {
this.touchstartX = e.changedTouches[0].screenX
},
touchend(e) {
this.touchendX = e.changedTouches[0].screenX
this.handleGesture()
},
registerListeners() {
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
}
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>

View file

@ -0,0 +1,70 @@
<template>
<div class="w-40">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
text: {
type: String,
default: 'Please Wait...'
}
}
}
</script>
<style>
.loader-dots div {
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.loader-dots div:nth-child(1) {
left: 8px;
animation: loader-dots1 0.6s infinite;
}
.loader-dots div:nth-child(2) {
left: 8px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(3) {
left: 32px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(4) {
left: 56px;
animation: loader-dots3 0.6s infinite;
}
@keyframes loader-dots1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-dots3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-dots2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>

View file

@ -7,6 +7,7 @@
<app-stream-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @selectDownload="selectDownload" @deleteDownload="deleteDownload" />
<modals-libraries-modal />
<readers-reader />
</div>
</template>

146
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.4.0-beta",
"version": "v0.9.6-beta",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -2711,6 +2711,14 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz",
"integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ=="
},
"@types/localforage": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz",
"integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=",
"requires": {
"localforage": "*"
}
},
"@types/node": {
"version": "16.4.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.4.12.tgz",
@ -5134,6 +5142,15 @@
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
},
"d": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"requires": {
"es5-ext": "^0.10.50",
"type": "^1.0.1"
}
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -5564,6 +5581,29 @@
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="
},
"epubjs": {
"version": "0.3.88",
"resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.88.tgz",
"integrity": "sha512-VRumULpUELYmYwzypyfbDwoSIqDp2LXOXCtY3o55o3YDW5Zm32UjtZuX/xaWFGqyZORNNMWWQ8VlMaY1djnDYg==",
"requires": {
"@types/localforage": "0.0.34",
"core-js": "^3.6.5",
"event-emitter": "^0.3.5",
"jszip": "^3.4.0",
"localforage": "^1.7.3",
"lodash": "^4.17.15",
"marks-pane": "^1.0.9",
"path-webpack": "0.0.3",
"xmldom": "^0.3.0"
},
"dependencies": {
"xmldom": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.3.0.tgz",
"integrity": "sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g=="
}
}
},
"errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
@ -5622,12 +5662,41 @@
"is-symbol": "^1.0.2"
}
},
"es5-ext": {
"version": "0.10.53",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
"requires": {
"es6-iterator": "~2.0.3",
"es6-symbol": "~3.1.3",
"next-tick": "~1.0.0"
}
},
"es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"optional": true
},
"es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
"requires": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"es6-symbol": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"requires": {
"d": "^1.0.1",
"ext": "^1.1.2"
}
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -5687,6 +5756,15 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
"requires": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@ -5777,6 +5855,21 @@
}
}
},
"ext": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
"integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==",
"requires": {
"type": "^2.5.0"
},
"dependencies": {
"type": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
"integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
}
}
},
"extend-shallow": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
@ -7438,6 +7531,27 @@
"universalify": "^2.0.0"
}
},
"jszip": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
"integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"set-immediate-shim": "~1.0.1"
},
"dependencies": {
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"requires": {
"immediate": "~3.0.5"
}
}
}
},
"keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@ -7610,6 +7724,11 @@
"launch-editor": "^2.2.1"
}
},
"libarchive.js": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libarchive.js/-/libarchive.js-1.3.0.tgz",
"integrity": "sha512-EkQfRXt9DhWwj6BnEA2TNpOf4jTnzSTUPGgE+iFxcdNqjktY8GitbDeHnx8qZA0/IukNyyBUR3oQKRdYkO+HFg=="
},
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
@ -7780,6 +7899,11 @@
"object-visit": "^1.0.0"
}
},
"marks-pane": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz",
"integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="
},
"matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -8147,6 +8271,11 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"next-tick": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
},
"no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@ -8775,6 +8904,11 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
},
"path-webpack": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz",
"integrity": "sha1-/23sdJ7sWpRgXATV9j/FVgegOhY="
},
"pbkdf2": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
@ -12202,6 +12336,11 @@
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
"integrity": "sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0="
},
"set-immediate-shim": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
"integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
},
"set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
@ -13364,6 +13503,11 @@
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"optional": true
},
"type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
},
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.9.6-beta",
"version": "v0.9.7-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",
@ -23,7 +23,9 @@
"axios": "^0.21.1",
"capacitor-data-storage-sqlite": "^3.2.0",
"core-js": "^3.15.1",
"epubjs": "^0.3.88",
"hls.js": "^1.0.9",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.7",
"socket.io-client": "^4.1.3",
"vue-toastification": "^1.7.11"

View file

@ -7,14 +7,14 @@
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<div class="flex my-4">
<p class="text-sm">{{ numTracks }} Tracks</p>
<p v-if="numTracks" class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p class="text-gray-300 text-sm my-1">
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
{{ $elapsedPretty(duration) }}<span class="px-4">{{ $bytesPretty(size) }}</span>
</p>
@ -26,13 +26,17 @@
</div>
</div>
<div v-if="isConnected || isDownloadPlayable" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center w-full mr-2" :padding-x="4" @click="playClick">
<div v-if="(isConnected && (showPlay || showRead)) || isDownloadPlayable" class="flex mt-4 -mr-2">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
</ui-btn>
<ui-btn v-if="isConnected" color="primary" :disabled="isPlaying" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="isDownloaded ? 'animate-pulse' : ''">{{ downloadObj ? (isDownloading || isDownloadPreparing ? 'downloading' : 'download_done') : 'download' }}</span>
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="isConnected && showPlay" color="primary" :disabled="isPlaying" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadObj ? 'animate-pulse' : ''">{{ downloadObj ? (isDownloading || isDownloadPreparing ? 'downloading' : 'download_done') : 'download' }}</span>
</ui-btn>
</div>
</div>
@ -150,9 +154,28 @@ export default {
if (this.audiobook.tracks) return this.audiobook.tracks.length
return this.audiobook.numTracks || 0
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
isDownloading() {
return this.downloadObj ? this.downloadObj.isDownloading : false
},
showPlay() {
return !this.isMissing && !this.isIncomplete && this.numTracks
},
showRead() {
return this.hasEbook && this.ebookFormat !== '.pdf'
},
hasEbook() {
return this.audiobook.numEbooks
},
ebookFormat() {
if (!this.audiobook || !this.audiobook.ebooks || !this.audiobook.ebooks.length) return null
return this.audiobook.ebooks[0].ext.substr(1)
},
isDownloadPreparing() {
return this.downloadObj ? this.downloadObj.isPreparing : false
},
@ -170,6 +193,9 @@ export default {
}
},
methods: {
readBook() {
this.$store.commit('openReader', this.audiobook)
},
playClick() {
this.$store.commit('setPlayOnLoad', true)
if (!this.isDownloadPlayable) {

View file

@ -76,6 +76,18 @@ Vue.directive('click-outside', {
}
})
function xmlToJson(xml) {
const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
const key = res[1] || res[3];
const value = res[2] && xmlToJson(res[2]);
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
}
return json;
}
Vue.prototype.$xmlToJson = xmlToJson
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,9 @@ export const state = () => ({
networkConnectionType: null,
streamListener: null,
isFirstLoad: true,
hasStoragePermission: false
hasStoragePermission: false,
selectedBook: null,
showReader: false
})
export const getters = {
@ -80,5 +82,12 @@ export const mutations = {
},
removeStreamListener(state) {
state.streamListener = null
},
openReader(state, audiobook) {
state.selectedBook = audiobook
state.showReader = true
},
setShowReader(state, val) {
state.showReader = val
}
}