Skip to content

Commit

Permalink
feat: support syosetu
Browse files Browse the repository at this point in the history
  • Loading branch information
IronKinoko committed Jan 8, 2024
1 parent 1917ce4 commit 9e253d8
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/novel-speech-synthesis/meta.template
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// @match https://www.bilixs.com/*
// @match https://www.bilinovel.com/*
// @match https://www.linovelib.com/*
// @match https://novel18.syosetu.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=
// @grant none
// @noframes
Expand Down
21 changes: 21 additions & 0 deletions packages/novel-speech-synthesis/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,25 @@ export const adapter: Record<string, SpeechOptions> = {
?.click()
},
},
'novel18.syosetu': {
container: 'body',
lang: 'ja-JP',
getParagraph: () => {
const content = document.querySelector('#novel_honbun')
if (!content) throw new Error('content not found')

return Array.from(content.querySelectorAll('p'))
},
nextChapter: () => {
let dom = document.querySelector<HTMLAnchorElement>(
'.novel_bn a[rel="next"]'
)
if (!dom) {
dom = document.querySelector<HTMLAnchorElement>(
'.novel_bn a:last-child'
)
}
dom?.click()
},
},
}
12 changes: 12 additions & 0 deletions packages/novel-speech-synthesis/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ router({
},
],
})

router({
domain: ['novel18.syosetu.com'],
routes: [
{
pathname: /^\/([^/]+?)\/([^/]+?)\/?$/,
run: () => {
new Speech(adapter['novel18.syosetu'])
},
},
],
})
34 changes: 30 additions & 4 deletions packages/novel-speech-synthesis/src/speech/index.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
#speech {
position: fixed;
bottom: calc(env(safe-area-inset-bottom) + 16px);
right: 16px;
z-index: 999999999;
border-radius: 8px;
border: 1px solid #ddd;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
transform: translateX(0);
opacity: 1;
box-sizing: border-box;
overflow: hidden;
box-shadow: rgba(0, 0, 0, 0.2) -1px 1px 10px 0px;

&.left {
border-radius: 0 8px 8px 0;
--transform-x: -100%;
}
&.right {
border-radius: 8px 0 0 8px;
--transform-x: 100%;
}
&.hide {
opacity: 0;
pointer-events: none;
transform: translateX(var(--transform-x));
}

* {
box-sizing: border-box;
Expand All @@ -22,11 +38,13 @@
.speech-controls-button {
padding: 0 4px;
width: 35px;
height: 28px;
text-align: center;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
.speech-controls-button + .speech-controls-button {
border-left: 1px solid #ddd;
Expand Down Expand Up @@ -65,6 +83,14 @@
color: #1890ff;
}
}
.speech-controls-menu {
input:checked + svg {
color: #1890ff;
}
}
.speech-controls-hide {
display: none !important;
}
}
.speech-reading {
text-decoration: underline !important;
Expand Down
152 changes: 144 additions & 8 deletions packages/novel-speech-synthesis/src/speech/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { keybind } from 'shared'
import { keybind, local, throttle } from 'shared'
import './index.scss'
import T from './ui.template.html'

Expand All @@ -12,16 +12,19 @@ export type SpeechOptions = {

export default class Speech {
private elements: {
root: HTMLDivElement
play: HTMLInputElement
voice: HTMLSelectElement
rate: HTMLSelectElement
continuous: HTMLInputElement
menu: HTMLInputElement
} | null = null

private utterance = {
rate: 1.5,
voiceURI: null as string | null,
continuous: true,
menu: true,
}
private voices: SpeechSynthesisVoice[] = []
private paragraphList = [] as HTMLElement[]
Expand Down Expand Up @@ -62,21 +65,23 @@ export default class Speech {
}

private createUI() {
const dom = new DOMParser().parseFromString(T.speech, 'text/html').body
.children[0]
const root = new DOMParser().parseFromString(T.speech, 'text/html').body
.children[0] as HTMLDivElement
const container = document.querySelector(this.opts.container)
if (!container) throw new Error('container not found')
container.appendChild(dom)
container.appendChild(root)

window.addEventListener('beforeunload', () => {
this.cancel()
})

this.elements = {
play: dom.querySelector('.speech-controls-play input')!,
voice: dom.querySelector('.speech-controls-voice')!,
rate: dom.querySelector('.speech-controls-rate')!,
continuous: dom.querySelector('.speech-controls-continuous input')!,
root,
play: root.querySelector('.speech-controls-play input')!,
voice: root.querySelector('.speech-controls-voice')!,
rate: root.querySelector('.speech-controls-rate')!,
continuous: root.querySelector('.speech-controls-continuous input')!,
menu: root.querySelector('.speech-controls-menu input')!,
}
this.elements.play.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement
Expand Down Expand Up @@ -131,12 +136,143 @@ export default class Speech {
this.saveUtterance()
})

this.elements.menu.checked = this.utterance.menu
this.elements.menu.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement
this.utterance.menu = target.checked
this.updateMenuUI()
this.saveUtterance()
})

this.updateMenuUI()

this.addDragEvent(root)

keybind(['space'], (e) => {
e.preventDefault()
this.elements!.play.click()
})
}

private addDragEvent(fxiedDom: HTMLDivElement) {
let prevY = 0
let storeY = 0
const key = 'speech-fixed-position'
let position = local.getItem(key, {
top: document.documentElement.clientHeight / 4,
left: document.documentElement.clientWidth,
})

// safe position area
const safeArea = {
top: (y: number) =>
Math.min(
Math.max(y, 0),
document.documentElement.clientHeight -
fxiedDom!.getBoundingClientRect().height
),
left: (x: number) =>
Math.min(
Math.max(x, 0),
document.documentElement.clientWidth -
fxiedDom!.getBoundingClientRect().width
),
}

// set fixedNextBtn position
const setPosition = (
position: { left: number; top: number },
isMoving: boolean
) => {
fxiedDom!.classList.remove('left', 'right')
fxiedDom!.style.transition = isMoving ? 'none' : ''
fxiedDom!.style.top = `${position.top}px`
fxiedDom!.style.left = `${position.left}px`

if (!isMoving) {
const halfScreenWidth = document.documentElement.clientWidth / 2
fxiedDom!.classList.add(
position.left > halfScreenWidth ? 'right' : 'left'
)
fxiedDom!.style.left =
position.left > halfScreenWidth
? `${
document.documentElement.clientWidth -
fxiedDom!.getBoundingClientRect().width
}px`
: '0px'
}
}

setPosition(position, false)

// remember fixedNextBtn move position
fxiedDom.addEventListener('touchstart', (e) => {
const touch = e.touches[0]
const { clientX, clientY } = touch
const { top, left } = fxiedDom!.getBoundingClientRect()
const diffX = clientX - left
const diffY = clientY - top
const move = (e: TouchEvent) => {
e.preventDefault()
e.stopPropagation()
const touch = e.touches[0]
const { clientX, clientY } = touch
const x = safeArea.left(clientX - diffX)
const y = safeArea.top(clientY - diffY)
position = { top: y, left: x }
setPosition(position, true)
}
const end = () => {
local.setItem(key, position)
setPosition(position, false)
fxiedDom!.style.removeProperty('transition')
window.removeEventListener('touchmove', move)
window.removeEventListener('touchend', end)
}
window.addEventListener('touchmove', move, { passive: false })
window.addEventListener('touchend', end)
})

window.addEventListener(
'scroll',
throttle(() => {
const dom = document.scrollingElement!

const currentY = dom.scrollTop
let diffY = currentY - storeY
if (
currentY < 50 ||
currentY + dom.clientHeight > dom.scrollHeight - 800 ||
diffY < -30
) {
fxiedDom?.classList.remove('hide')
} else {
fxiedDom?.classList.add('hide')
}

if (currentY > prevY) {
storeY = currentY
}
prevY = currentY
}, 100)
)
}

private updateMenuUI() {
this.elements!.root.querySelectorAll('.speech-controls-button').forEach(
(dom) => {
if (dom.classList.contains('speech-controls-menu')) return

if (this.utterance.menu) {
dom.classList.remove('speech-controls-hide')
} else {
dom.classList.add('speech-controls-hide')
}
}
)
}

private refreshSpeech() {
const idx = this.currentSpeakingParagraphIdx
if (idx === null) return
Expand Down
19 changes: 19 additions & 0 deletions packages/novel-speech-synthesis/src/speech/ui.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
</label>
<select class="speech-controls-button speech-controls-voice"></select>
<select class="speech-controls-button speech-controls-rate">
<option value="0.25">0.25x</option>
<option value="0.3">0.3x</option>
<option value="0.4">0.4x</option>
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
Expand All @@ -50,6 +55,20 @@
<input type="checkbox" hidden />
<span>C</span>
</label>
<label class="speech-controls-button speech-controls-menu">
<input type="checkbox" hidden />
<svg
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
>
<path
d="M904 160H120c-4.4 0-8 3.6-8 8v64c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-64c0-4.4-3.6-8-8-8zM904 784H120c-4.4 0-8 3.6-8 8v64c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-64c0-4.4-3.6-8-8-8zM904 472H120c-4.4 0-8 3.6-8 8v64c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-64c0-4.4-3.6-8-8-8z"
fill="currentColor"
></path>
</svg>
</label>
</div>
</div>
</div>

0 comments on commit 9e253d8

Please sign in to comment.