feat: Daily-style wave/particles, skip buttons inline, speed to settings
- Replace Canvas wave with CSS div bars + sparkle animation (Daily style) - Add radial glow behind waveform + particle burst around play button - Play button: Daily style (glow, inset highlight, pulse ring when playing) - Move skip-back/forward next to play button (horizontal row) - Remove separate controls bar - Move speed selector into settings panel - Add VOICEVOX speaker selector in settings panel (ずんだもん/春日部つむぎ/四国めたん) - Article list: show publishedAt time ago next to source - Article list: external-link icon button on right (opens URL in new tab) - stopPropagation on link button so tap doesn't trigger playback jump Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c81dcf06f
commit
40efd9b8e3
701
index.html
701
index.html
|
|
@ -39,7 +39,15 @@
|
|||
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
||||
|
||||
<style>
|
||||
/* ── base.css の main を没入型レイアウト用に上書き ── */
|
||||
/* ── アクセントグロー変数 ─────────────────────────────── */
|
||||
:root {
|
||||
--accent-glow: rgba(110, 231, 183, 0.3);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--accent-glow: rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
/* ── base.css の main を没入型レイアウト用に上書き ────── */
|
||||
main {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
|
|
@ -51,7 +59,7 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
/* ── トップバー ────────────────────────────────────── */
|
||||
/* ── トップバー ──────────────────────────────────────────── */
|
||||
.brief-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -59,11 +67,7 @@
|
|||
padding: max(14px, env(safe-area-inset-top)) 16px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brief-date {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text2);
|
||||
}
|
||||
.brief-date { font-size: 14px; font-weight: 400; color: var(--text2); }
|
||||
.brief-sep { color: var(--border); font-size: 14px; }
|
||||
.brief-count { font-size: 14px; color: var(--text3); }
|
||||
.brief-top-right {
|
||||
|
|
@ -87,12 +91,11 @@
|
|||
}
|
||||
.brief-icon-btn:active { background: var(--surface); color: var(--text2); }
|
||||
|
||||
/* ── 現在再生中の記事情報(上部に固定配置) ──────── */
|
||||
/* ── 現在再生中の記事情報(最上部) ─────────────────────── */
|
||||
.brief-now-playing {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 80px;
|
||||
}
|
||||
.brief-article-meta {
|
||||
font-size: 11px;
|
||||
|
|
@ -113,183 +116,163 @@
|
|||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.brief-article-source {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
}
|
||||
.brief-article-source { font-size: 13px; color: var(--text3); }
|
||||
|
||||
/* ── プレーヤー(波形 + 再生ボタンのみ)flex: 1 ─── */
|
||||
/* ── プレーヤー(波形 + 再生コントロール)flex: 1 ─────── */
|
||||
.brief-player {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0 24px;
|
||||
min-height: 100px;
|
||||
padding: 0 20px;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── 波形キャンバス ─────────────────────────────── */
|
||||
.brief-wave-wrap {
|
||||
/* ── 波形エリア ────────────────────────────────────────── */
|
||||
.brief-visual {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
margin: 0 0 22px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
#waveCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
.brief-visual-glow {
|
||||
position: absolute;
|
||||
width: 240px;
|
||||
height: 70px;
|
||||
background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%);
|
||||
filter: blur(20px);
|
||||
z-index: 0;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 再生ボタン ──────────────────────────────────── */
|
||||
.brief-play-btn {
|
||||
width: 72px;
|
||||
.brief-waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.brief-wave-bar {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
background: var(--accent);
|
||||
color: #0D0D0D;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
transition: height 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.12s, opacity 0.12s;
|
||||
box-shadow: 0 0 0 0 rgba(110,231,183,0);
|
||||
}
|
||||
.brief-play-btn:active { transform: scale(0.94); }
|
||||
.brief-play-btn.playing {
|
||||
animation: playGlow 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes playGlow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(110,231,183,0.35); }
|
||||
50% { box-shadow: 0 0 0 14px rgba(110,231,183,0); }
|
||||
}
|
||||
.brief-progress {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
margin-top: 14px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
/* ── コントロールバー ────────────────────────────── */
|
||||
.brief-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 24px 10px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border);
|
||||
@keyframes brief-wave-sparkle {
|
||||
0%, 100% {
|
||||
filter: brightness(1) drop-shadow(0 0 5px var(--accent-glow));
|
||||
background: var(--accent);
|
||||
}
|
||||
.brief-ctrl-btn {
|
||||
flex: 1;
|
||||
50% {
|
||||
filter: brightness(2.5) drop-shadow(0 0 20px #fff);
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
.brief-wave-bar.sparkle {
|
||||
animation: brief-wave-sparkle 0.4s infinite alternate cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── 再生コントロール行(skip · play · skip) ────────── */
|
||||
.brief-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
position: relative;
|
||||
}
|
||||
.brief-skip-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.brief-ctrl-btn:active { background: var(--surface); color: var(--text); }
|
||||
.brief-speed-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 2;
|
||||
justify-content: center;
|
||||
}
|
||||
.brief-speed-btn {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
line-height: 1;
|
||||
}
|
||||
.brief-speed-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
border-radius: 50%;
|
||||
transition: color 0.12s, transform 0.1s;
|
||||
}
|
||||
.brief-skip-btn:active { transform: scale(0.9); color: var(--text); }
|
||||
|
||||
/* ── 記事リスト ───────────────────────────────────── */
|
||||
.brief-list {
|
||||
flex-shrink: 0;
|
||||
max-height: 42dvh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.brief-list::-webkit-scrollbar { display: none; }
|
||||
.brief-list-item {
|
||||
/* Daily スタイル再生ボタン */
|
||||
.brief-play-main {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.brief-list-item:active { background: var(--surface); }
|
||||
.brief-list-item.active { background: var(--surface); }
|
||||
.brief-list-num {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.brief-list-num.active { color: var(--accent); font-weight: 600; }
|
||||
.brief-list-info { flex: 1; min-width: 0; }
|
||||
.brief-list-title {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.brief-list-title.active { color: var(--text); font-weight: 500; }
|
||||
.brief-list-source {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 音声エンジン表示 ───────────────────────────── */
|
||||
.voice-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 40px var(--accent-glow), inset 0 0 15px rgba(255,255,255,0.3);
|
||||
transition: transform 0.2s cubic-bezier(.4, 0, .2, 1);
|
||||
position: relative;
|
||||
}
|
||||
.brief-play-main::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
.brief-play-main.playing::after {
|
||||
opacity: 0.2;
|
||||
animation: brief-pulse-ring 2s linear infinite;
|
||||
}
|
||||
@keyframes brief-pulse-ring {
|
||||
0% { transform: scale(1); opacity: 0.4; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
.brief-play-main:active { transform: scale(0.92); }
|
||||
.brief-play-inner {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000;
|
||||
}
|
||||
.voice-badge.voicevox { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── 設定パネル内の入力 ─────────────────────────── */
|
||||
/* パーティクル */
|
||||
.brief-particles {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.brief-particle {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
@keyframes brief-particle-sparkle {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
100% { transform: translate(var(--tx), var(--ty)) scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── 設定パネル内の入力・スピード ────────────────────── */
|
||||
.settings-text-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
|
@ -324,6 +307,108 @@
|
|||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* speed/speaker ボタングループ */
|
||||
.brief-seg-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.brief-seg-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
line-height: 1;
|
||||
}
|
||||
.brief-seg-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ── 音声エンジン表示 ──────────────────────────────── */
|
||||
.voice-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.voice-badge.voicevox { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── 記事リスト ─────────────────────────────────────── */
|
||||
.brief-list {
|
||||
flex-shrink: 0;
|
||||
max-height: 40dvh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.brief-list::-webkit-scrollbar { display: none; }
|
||||
.brief-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 16px 11px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.brief-list-item:active { background: var(--surface); }
|
||||
.brief-list-item.active { background: var(--surface); }
|
||||
.brief-list-num {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.brief-list-num.active { color: var(--accent); font-weight: 600; }
|
||||
.brief-list-info { flex: 1; min-width: 0; }
|
||||
.brief-list-title {
|
||||
font-size: 14px;
|
||||
color: var(--text2);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.brief-list-title.active { color: var(--text); font-weight: 500; }
|
||||
.brief-list-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.brief-list-link {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text3);
|
||||
transition: color 0.12s, background 0.12s;
|
||||
text-decoration: none;
|
||||
align-self: center;
|
||||
}
|
||||
.brief-list-link:active { background: var(--border); color: var(--text2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -338,6 +423,7 @@
|
|||
</div>
|
||||
<div class="settings-panel-body">
|
||||
|
||||
<!-- 外観 -->
|
||||
<div>
|
||||
<div class="settings-group-label">外観</div>
|
||||
<div class="settings-item">
|
||||
|
|
@ -356,10 +442,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 再生 -->
|
||||
<div style="margin-top:20px">
|
||||
<div class="settings-group-label">再生</div>
|
||||
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:0">
|
||||
<div class="settings-item-label">再生速度</div>
|
||||
<div class="brief-seg-group" id="speedGroup">
|
||||
<button class="brief-seg-btn" data-speed="0.8">0.8x</button>
|
||||
<button class="brief-seg-btn active" data-speed="1.0">1.0x</button>
|
||||
<button class="brief-seg-btn" data-speed="1.25">1.25x</button>
|
||||
<button class="brief-seg-btn" data-speed="1.5">1.5x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:0;margin-top:12px">
|
||||
<div class="settings-item-label">音声キャラクター(VOICEVOX)</div>
|
||||
<div class="brief-seg-group" id="speakerGroup">
|
||||
<button class="brief-seg-btn active" data-speaker="1">ずんだもん</button>
|
||||
<button class="brief-seg-btn" data-speaker="3">春日部つむぎ</button>
|
||||
<button class="brief-seg-btn" data-speaker="2">四国めたん</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posimai API Key -->
|
||||
<div style="margin-top:20px">
|
||||
<div class="settings-group-label">Posimai API Key</div>
|
||||
<p class="settings-field-label">
|
||||
設定すると Synology 経由でずんだもん音声が使えます(VOICEVOX 未設定時はブラウザ音声で再生)。
|
||||
設定すると Synology 経由でずんだもん音声が使えます(未設定時はブラウザ音声)。
|
||||
</p>
|
||||
<input class="settings-text-input" id="apiKeyInput" type="password"
|
||||
placeholder="posimai_api_key" autocomplete="off">
|
||||
|
|
@ -391,7 +500,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 現在再生中の記事情報(最上部) -->
|
||||
<!-- 現在再生中の記事情報 -->
|
||||
<div class="brief-now-playing">
|
||||
<div class="brief-article-meta" id="briefMeta">Posimai Brief</div>
|
||||
<div class="brief-article-title" id="briefTitle">記事を読み込んでいます</div>
|
||||
|
|
@ -401,34 +510,32 @@
|
|||
<!-- プレーヤー(波形 + 再生ボタン) -->
|
||||
<div class="brief-player" id="briefPlayer">
|
||||
|
||||
<div class="brief-wave-wrap">
|
||||
<canvas id="waveCanvas"></canvas>
|
||||
<!-- 波形 -->
|
||||
<div class="brief-visual">
|
||||
<div class="brief-visual-glow" id="briefGlow"></div>
|
||||
<div class="brief-waveform" id="briefWaveform"></div>
|
||||
</div>
|
||||
|
||||
<button class="brief-play-btn" id="playBtn" aria-label="再生">
|
||||
<i data-lucide="play" style="width:30px;height:30px;stroke-width:2;fill:#0D0D0D"></i>
|
||||
<!-- skip-back · play · skip-forward -->
|
||||
<div class="brief-controls-row">
|
||||
<button class="brief-skip-btn" id="prevBtn" aria-label="前の記事">
|
||||
<i data-lucide="skip-back" style="width:24px;height:24px;stroke-width:2"></i>
|
||||
</button>
|
||||
|
||||
<div class="brief-progress" id="briefProgress"></div>
|
||||
|
||||
<div style="position:relative">
|
||||
<div class="brief-particles" id="briefParticles"></div>
|
||||
<button class="brief-play-main" id="playBtn" aria-label="再生">
|
||||
<div class="brief-play-inner" id="playInner">
|
||||
<i data-lucide="play" style="fill:#000;width:32px;height:32px;stroke-width:2"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- コントロール -->
|
||||
<div class="brief-controls">
|
||||
<button class="brief-ctrl-btn" id="prevBtn" aria-label="前の記事">
|
||||
<i data-lucide="skip-back" style="width:20px;height:20px;stroke-width:2"></i>
|
||||
<button class="brief-skip-btn" id="nextBtn" aria-label="次の記事">
|
||||
<i data-lucide="skip-forward" style="width:24px;height:24px;stroke-width:2"></i>
|
||||
</button>
|
||||
|
||||
<div class="brief-speed-group">
|
||||
<button class="brief-speed-btn" data-speed="0.8">0.8x</button>
|
||||
<button class="brief-speed-btn active" data-speed="1.0">1.0x</button>
|
||||
<button class="brief-speed-btn" data-speed="1.25">1.25x</button>
|
||||
<button class="brief-speed-btn" data-speed="1.5">1.5x</button>
|
||||
</div>
|
||||
|
||||
<button class="brief-ctrl-btn" id="nextBtn" aria-label="次の記事">
|
||||
<i data-lucide="skip-forward" style="width:20px;height:20px;stroke-width:2"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 記事リスト -->
|
||||
|
|
@ -454,77 +561,84 @@ let articles = [];
|
|||
let speechQueue = [];
|
||||
let currentIdx = 0;
|
||||
let isPlaying = false;
|
||||
let speechRate = 1.0;
|
||||
let speechRate = parseFloat(localStorage.getItem('posimai-brief-rate') || '1.0');
|
||||
let ttsSpeaker = parseInt(localStorage.getItem('posimai-brief-speaker') || '1', 10);
|
||||
let audioCtx = null;
|
||||
let currentSource = null;
|
||||
|
||||
// ── 波形(CSS divバー、Daily スタイル) ──────────────────────
|
||||
const WAVE_BAR_COUNT = 36;
|
||||
const waveBars = [];
|
||||
let waveRAF = null;
|
||||
let lastWaveUpdate = 0;
|
||||
|
||||
// ── 波形データ ────────────────────────────────────────────────
|
||||
const BAR_COUNT = 64;
|
||||
const BAR_W = 2;
|
||||
const BAR_GAP = 2;
|
||||
const barH = new Float32Array(BAR_COUNT).fill(0.04);
|
||||
const barT = new Float32Array(BAR_COUNT).fill(0.04);
|
||||
const barSpk = new Float32Array(BAR_COUNT);
|
||||
let wavePhase = 0;
|
||||
(function initWave() {
|
||||
const container = document.getElementById('briefWaveform');
|
||||
for (let i = 0; i < WAVE_BAR_COUNT; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'brief-wave-bar';
|
||||
container.appendChild(bar);
|
||||
waveBars.push(bar);
|
||||
}
|
||||
})();
|
||||
|
||||
function updateWave(playing) {
|
||||
wavePhase += playing ? 0.06 : 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (playing) {
|
||||
const env = 0.15 + 0.5 * Math.abs(Math.sin(wavePhase - i * 0.18));
|
||||
if (Math.random() < 0.07) barT[i] = env + Math.random() * (1 - env);
|
||||
else if (Math.random() < 0.1) barT[i] = 0.04 + Math.random() * 0.12;
|
||||
if (Math.random() < 0.007) barSpk[i] = 1.0;
|
||||
} else {
|
||||
if (Math.random() < 0.04) barT[i] = 0.02 + Math.random() * 0.05;
|
||||
}
|
||||
barH[i] += (barT[i] - barH[i]) * (playing ? 0.28 : 0.12);
|
||||
barSpk[i] *= 0.76;
|
||||
function updateWaveRAF() {
|
||||
if (!isPlaying) return;
|
||||
const now = Date.now();
|
||||
if (now - lastWaveUpdate >= 150) {
|
||||
lastWaveUpdate = now;
|
||||
const time = now * 0.003;
|
||||
waveBars.forEach((bar, i) => {
|
||||
const sine = Math.sin(time + i * 0.45) * 22;
|
||||
const h = Math.max(6, 28 + sine + Math.random() * 16);
|
||||
bar.style.height = h + 'px';
|
||||
if (Math.random() > 0.93) bar.classList.add('sparkle');
|
||||
else bar.classList.remove('sparkle');
|
||||
});
|
||||
}
|
||||
waveRAF = requestAnimationFrame(updateWaveRAF);
|
||||
}
|
||||
|
||||
function drawWave(playing) {
|
||||
const canvas = document.getElementById('waveCanvas');
|
||||
if (!canvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) {
|
||||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const totalW = BAR_COUNT * (BAR_W + BAR_GAP) - BAR_GAP;
|
||||
const ox = (W - totalW * dpr) / 2;
|
||||
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const h = Math.max(3 * dpr, Math.round(H * barH[i]));
|
||||
const spk = barSpk[i];
|
||||
const x = ox + i * (BAR_W + BAR_GAP) * dpr;
|
||||
const y = (H - h) / 2;
|
||||
|
||||
if (spk > 0.12) {
|
||||
const t = spk;
|
||||
const rv = Math.round(110 + (255 - 110) * t);
|
||||
const gv = Math.round(231 + (255 - 231) * t);
|
||||
const bv = Math.round(183 + (255 - 183) * t);
|
||||
ctx.fillStyle = `rgba(${rv},${gv},${bv},${0.5 + t * 0.5})`;
|
||||
} else {
|
||||
const alpha = playing ? 0.55 + barH[i] * 0.45 : 0.18;
|
||||
ctx.fillStyle = `rgba(110,231,183,${alpha})`;
|
||||
}
|
||||
ctx.fillRect(x, y, BAR_W * dpr, h);
|
||||
}
|
||||
function stopWave() {
|
||||
cancelAnimationFrame(waveRAF);
|
||||
waveRAF = null;
|
||||
waveBars.forEach(bar => { bar.style.height = '8px'; bar.classList.remove('sparkle'); });
|
||||
}
|
||||
|
||||
function waveLoop() {
|
||||
updateWave(isPlaying);
|
||||
drawWave(isPlaying);
|
||||
waveRAF = requestAnimationFrame(waveLoop);
|
||||
// ── パーティクル ──────────────────────────────────────────────
|
||||
let particleInterval = null;
|
||||
|
||||
function createParticle() {
|
||||
if (!isPlaying) return;
|
||||
const container = document.getElementById('briefParticles');
|
||||
const p = document.createElement('div');
|
||||
p.className = 'brief-particle';
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 50 + Math.random() * 55;
|
||||
const size = 2 + Math.random() * 3;
|
||||
const dur = 0.6 + Math.random() * 0.4;
|
||||
p.style.cssText = `
|
||||
width:${size}px; height:${size}px;
|
||||
left:50%; top:50%;
|
||||
--tx:${Math.cos(angle) * dist}px;
|
||||
--ty:${Math.sin(angle) * dist}px;
|
||||
animation: brief-particle-sparkle ${dur}s cubic-bezier(0,.5,.5,1) forwards;
|
||||
`;
|
||||
container.appendChild(p);
|
||||
setTimeout(() => p.remove(), 1000);
|
||||
}
|
||||
|
||||
function startEffects() {
|
||||
updateWaveRAF();
|
||||
particleInterval = setInterval(createParticle, 45);
|
||||
document.getElementById('briefGlow').style.opacity = '0.8';
|
||||
}
|
||||
|
||||
function stopEffects() {
|
||||
stopWave();
|
||||
clearInterval(particleInterval);
|
||||
particleInterval = null;
|
||||
document.getElementById('briefGlow').style.opacity = '0.3';
|
||||
}
|
||||
|
||||
// ── 音声制御 ──────────────────────────────────────────────────
|
||||
|
|
@ -557,11 +671,10 @@ async function tryVoicevox(text) {
|
|||
if (!apiKey) return false;
|
||||
|
||||
const ctx = getAudioCtx();
|
||||
|
||||
const res = await fetch(TTS_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({ text: preprocessText(text), speaker: 1 }),
|
||||
body: JSON.stringify({ text: preprocessText(text), speaker: ttsSpeaker }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
|
|
@ -571,11 +684,9 @@ async function tryVoicevox(text) {
|
|||
return new Promise(resolve => {
|
||||
currentSource = ctx.createBufferSource();
|
||||
currentSource.buffer = buf;
|
||||
currentSource.playbackRate.value = speechRate;
|
||||
currentSource.connect(ctx.destination);
|
||||
currentSource.onended = () => {
|
||||
currentSource = null;
|
||||
resolve(true);
|
||||
};
|
||||
currentSource.onended = () => { currentSource = null; resolve(true); };
|
||||
currentSource.start(0);
|
||||
updateVoiceBadge(true);
|
||||
});
|
||||
|
|
@ -584,7 +695,8 @@ async function tryVoicevox(text) {
|
|||
function speakBrowser(text, onEnd) {
|
||||
const utter = new SpeechSynthesisUtterance(preprocessText(text));
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const ja = voices.find(v => v.lang.startsWith('ja') && v.localService)
|
||||
const ja = voices.find(v => v.lang.startsWith('ja') && !v.localService)
|
||||
|| voices.find(v => v.lang.startsWith('ja') && v.name.match(/Natural|Enhanced|Neural|Premium/))
|
||||
|| voices.find(v => v.lang.startsWith('ja'))
|
||||
|| null;
|
||||
if (ja) utter.voice = ja;
|
||||
|
|
@ -600,16 +712,11 @@ async function speak(text) {
|
|||
if (!isPlaying) return;
|
||||
stopAudio();
|
||||
|
||||
let voicevoxOk = false;
|
||||
try {
|
||||
const result = await tryVoicevox(text);
|
||||
voicevoxOk = result === true;
|
||||
} catch (e) {
|
||||
voicevoxOk = false;
|
||||
}
|
||||
let vvOk = false;
|
||||
try { vvOk = (await tryVoicevox(text)) === true; } catch(e) {}
|
||||
|
||||
if (!isPlaying) return;
|
||||
if (voicevoxOk) {
|
||||
if (vvOk) {
|
||||
onSpeakEnd();
|
||||
} else {
|
||||
speakBrowser(text, () => { if (isPlaying) onSpeakEnd(); });
|
||||
|
|
@ -626,6 +733,7 @@ function playNext() {
|
|||
if (currentIdx >= speechQueue.length) {
|
||||
isPlaying = false;
|
||||
updatePlayBtn();
|
||||
stopEffects();
|
||||
updateArticleDisplay(articles[articles.length - 1], articles.length - 1);
|
||||
return;
|
||||
}
|
||||
|
|
@ -639,8 +747,9 @@ function playNext() {
|
|||
// ── UI 更新 ───────────────────────────────────────────────────
|
||||
function updatePlayBtn() {
|
||||
const btn = document.getElementById('playBtn');
|
||||
const inner = document.getElementById('playInner');
|
||||
const icon = isPlaying ? 'pause' : 'play';
|
||||
btn.innerHTML = `<i data-lucide="${icon}" style="width:30px;height:30px;stroke-width:2;fill:#0D0D0D"></i>`;
|
||||
inner.innerHTML = `<i data-lucide="${icon}" style="fill:#000;width:32px;height:32px;stroke-width:2"></i>`;
|
||||
btn.classList.toggle('playing', isPlaying);
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
|
@ -653,14 +762,22 @@ function esc(str) {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return 'たった今';
|
||||
if (min < 60) return `${min}分前`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h}時間前`;
|
||||
return `${Math.floor(h / 24)}日前`;
|
||||
}
|
||||
|
||||
function updateArticleDisplay(a, idx) {
|
||||
if (!a) return;
|
||||
const total = articles.length;
|
||||
const num = idx + 1;
|
||||
document.getElementById('briefMeta').textContent = `${num} / ${total}`;
|
||||
document.getElementById('briefMeta').textContent = `${idx + 1} / ${articles.length}`;
|
||||
document.getElementById('briefTitle').textContent = a.title || '---';
|
||||
document.getElementById('briefSource').textContent = a.source || '';
|
||||
document.getElementById('briefProgress').textContent = '';
|
||||
updateListHighlight(idx);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
|
|
@ -675,15 +792,25 @@ function renderList() {
|
|||
const list = document.getElementById('briefList');
|
||||
list.innerHTML = '';
|
||||
articles.forEach((a, i) => {
|
||||
const item = document.createElement('div');
|
||||
const isActive = i === currentIdx;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'brief-list-item' + (isActive ? ' active' : '');
|
||||
|
||||
const timeStr = formatTimeAgo(a.publishedAt);
|
||||
const metaParts = [esc(a.source), timeStr].filter(Boolean);
|
||||
|
||||
item.innerHTML = `
|
||||
<span class="brief-list-num${isActive ? ' active' : ''}">${i + 1}</span>
|
||||
<div class="brief-list-info">
|
||||
<div class="brief-list-title${isActive ? ' active' : ''}">${esc(a.title)}</div>
|
||||
${a.source ? `<div class="brief-list-source">${esc(a.source)}</div>` : ''}
|
||||
</div>`;
|
||||
${metaParts.length ? `<div class="brief-list-meta">${metaParts.join('<span style="opacity:.4">·</span>')}</div>` : ''}
|
||||
</div>
|
||||
${a.url ? `<a class="brief-list-link" href="${esc(a.url)}" target="_blank" rel="noopener" aria-label="記事を開く">
|
||||
<i data-lucide="external-link" style="width:15px;height:15px;stroke-width:1.75"></i>
|
||||
</a>` : ''}
|
||||
`;
|
||||
|
||||
// 記事タップ → 音声再生ジャンプ
|
||||
item.addEventListener('click', () => {
|
||||
const wasPlaying = isPlaying;
|
||||
stopAudio();
|
||||
|
|
@ -693,11 +820,18 @@ function renderList() {
|
|||
if (wasPlaying) {
|
||||
isPlaying = true;
|
||||
updatePlayBtn();
|
||||
startEffects();
|
||||
playNext();
|
||||
}
|
||||
});
|
||||
|
||||
// 外部リンクは伝播させない
|
||||
const link = item.querySelector('.brief-list-link');
|
||||
if (link) link.addEventListener('click', e => e.stopPropagation());
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateListHighlight(idx) {
|
||||
|
|
@ -715,7 +849,7 @@ function updateListHighlight(idx) {
|
|||
function updateVoiceBadge(isVoicevox) {
|
||||
const badge = document.getElementById('voiceBadge');
|
||||
const label = document.getElementById('voiceBadgeLabel');
|
||||
label.textContent = isVoicevox ? 'ずんだもん' : 'ブラウザ音声';
|
||||
label.textContent = isVoicevox ? 'VOICEVOX' : 'ブラウザ音声';
|
||||
badge.classList.toggle('voicevox', isVoicevox);
|
||||
}
|
||||
|
||||
|
|
@ -726,9 +860,11 @@ document.getElementById('playBtn').addEventListener('click', () => {
|
|||
updatePlayBtn();
|
||||
if (isPlaying) {
|
||||
if (currentIdx >= speechQueue.length) currentIdx = 0;
|
||||
startEffects();
|
||||
playNext();
|
||||
} else {
|
||||
stopAudio();
|
||||
stopEffects();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -742,6 +878,7 @@ function skip(dir) {
|
|||
if (wasPlaying) {
|
||||
isPlaying = true;
|
||||
updatePlayBtn();
|
||||
startEffects();
|
||||
playNext();
|
||||
}
|
||||
}
|
||||
|
|
@ -751,31 +888,38 @@ document.getElementById('nextBtn').addEventListener('click', () => skip(1));
|
|||
|
||||
// MediaSession
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('play', () => { if (!isPlaying) { isPlaying = true; updatePlayBtn(); playNext(); } });
|
||||
navigator.mediaSession.setActionHandler('pause', () => { if (isPlaying) { isPlaying = false; updatePlayBtn(); stopAudio(); } });
|
||||
navigator.mediaSession.setActionHandler('play', () => { if (!isPlaying) { isPlaying = true; updatePlayBtn(); startEffects(); playNext(); } });
|
||||
navigator.mediaSession.setActionHandler('pause', () => { if (isPlaying) { isPlaying = false; updatePlayBtn(); stopAudio(); stopEffects(); } });
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => skip(-1));
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => skip(1));
|
||||
}
|
||||
|
||||
// ── 速度変更 ─────────────────────────────────────────────────
|
||||
document.querySelectorAll('.brief-speed-btn').forEach(btn => {
|
||||
// ── 再生速度 ─────────────────────────────────────────────────
|
||||
function initSpeedBtns() {
|
||||
document.querySelectorAll('#speedGroup .brief-seg-btn').forEach(btn => {
|
||||
const v = parseFloat(btn.dataset.speed);
|
||||
btn.classList.toggle('active', v === speechRate);
|
||||
btn.addEventListener('click', () => {
|
||||
speechRate = parseFloat(btn.dataset.speed);
|
||||
document.querySelectorAll('.brief-speed-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
localStorage.setItem('posimai-brief-rate', String(speechRate));
|
||||
if (isPlaying) {
|
||||
stopAudio();
|
||||
playNext();
|
||||
}
|
||||
speechRate = v;
|
||||
localStorage.setItem('posimai-brief-rate', String(v));
|
||||
document.querySelectorAll('#speedGroup .brief-seg-btn').forEach(b => b.classList.toggle('active', parseFloat(b.dataset.speed) === v));
|
||||
if (isPlaying) { stopAudio(); playNext(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const savedRate = parseFloat(localStorage.getItem('posimai-brief-rate') || '1.0');
|
||||
speechRate = savedRate;
|
||||
document.querySelectorAll('.brief-speed-btn').forEach(b => {
|
||||
b.classList.toggle('active', parseFloat(b.dataset.speed) === savedRate);
|
||||
});
|
||||
// ── 音声キャラクター ──────────────────────────────────────────
|
||||
function initSpeakerBtns() {
|
||||
document.querySelectorAll('#speakerGroup .brief-seg-btn').forEach(btn => {
|
||||
const v = parseInt(btn.dataset.speaker, 10);
|
||||
btn.classList.toggle('active', v === ttsSpeaker);
|
||||
btn.addEventListener('click', () => {
|
||||
ttsSpeaker = v;
|
||||
localStorage.setItem('posimai-brief-speaker', String(v));
|
||||
document.querySelectorAll('#speakerGroup .brief-seg-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.speaker, 10) === v));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── API キー保存 ──────────────────────────────────────────────
|
||||
document.getElementById('apiKeyInput').value = localStorage.getItem('posimai-brief-apikey') || '';
|
||||
|
|
@ -788,10 +932,7 @@ document.getElementById('apiKeySave').addEventListener('click', () => {
|
|||
// ── 日付表示 ─────────────────────────────────────────────────
|
||||
function updateDate() {
|
||||
const now = new Date();
|
||||
const mo = now.getMonth() + 1;
|
||||
const d = now.getDate();
|
||||
const day = DAYS_JP[now.getDay()];
|
||||
document.getElementById('briefDate').textContent = `${mo}月${d}日(${day})`;
|
||||
document.getElementById('briefDate').textContent = `${now.getMonth() + 1}月${now.getDate()}日(${DAYS_JP[now.getDay()]})`;
|
||||
}
|
||||
|
||||
// ── フィード読み込み ──────────────────────────────────────────
|
||||
|
|
@ -807,7 +948,6 @@ async function loadFeed() {
|
|||
if (!res.ok) throw new Error(`Feed ${res.status}`);
|
||||
const data = await res.json();
|
||||
const list = (data.articles || []).slice(0, 8);
|
||||
|
||||
if (!list.length) throw new Error('no articles');
|
||||
|
||||
articles = list;
|
||||
|
|
@ -831,9 +971,9 @@ async function loadFeed() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 再読み込みボタン ──────────────────────────────────────────
|
||||
// ── 再読み込み ────────────────────────────────────────────────
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
if (isPlaying) { isPlaying = false; stopAudio(); updatePlayBtn(); }
|
||||
if (isPlaying) { isPlaying = false; stopAudio(); stopEffects(); updatePlayBtn(); }
|
||||
loadFeed();
|
||||
});
|
||||
|
||||
|
|
@ -844,7 +984,8 @@ if ('serviceWorker' in navigator) {
|
|||
|
||||
// ── 初期化 ───────────────────────────────────────────────────
|
||||
updateDate();
|
||||
waveLoop();
|
||||
initSpeedBtns();
|
||||
initSpeakerBtns();
|
||||
loadFeed();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Reference in New Issue