2026-03-21 04:37:40 +00:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="ja" data-app-id="posimai-brief">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="robots" content="noindex, nofollow">
|
|
|
|
|
|
<script>
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
const p = new URLSearchParams(location.search);
|
|
|
|
|
|
const k = p.get('init_key');
|
|
|
|
|
|
if (k) {
|
|
|
|
|
|
localStorage.setItem('posimai-brief-apikey', k);
|
|
|
|
|
|
p.delete('init_key');
|
|
|
|
|
|
const u = location.pathname + (p.toString() ? '?' + p : '');
|
|
|
|
|
|
history.replaceState({}, '', u);
|
|
|
|
|
|
}
|
|
|
|
|
|
const t = localStorage.getItem('posimai-brief-theme') || 'system';
|
|
|
|
|
|
const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
|
|
|
|
|
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
|
|
|
|
|
document.documentElement.setAttribute('data-theme-pref', t);
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
|
|
|
|
<meta name="description" content="音声ニュースブリーフィング">
|
|
|
|
|
|
<meta name="color-scheme" content="dark light">
|
|
|
|
|
|
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
|
|
|
|
|
|
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
|
|
|
|
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
|
|
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
|
|
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
|
|
|
|
<meta name="apple-mobile-web-app-title" content="Brief">
|
|
|
|
|
|
<link rel="manifest" href="/manifest.json">
|
|
|
|
|
|
<link rel="icon" type="image/png" href="/logo.png">
|
|
|
|
|
|
<link rel="apple-touch-icon" href="/logo.png">
|
|
|
|
|
|
<title>Posimai Brief</title>
|
|
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|
|
|
|
|
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
|
|
|
|
|
|
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── base.css の main を没入型レイアウト用に上書き ── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
main {
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
max-width: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
height: 100dvh;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── トップバー ────────────────────────────────────── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.brief-top {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
padding: max(14px, env(safe-area-inset-top)) 16px 10px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.brief-date {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 14px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
font-weight: 400;
|
|
|
|
|
|
color: var(--text2);
|
|
|
|
|
|
}
|
2026-03-22 05:26:21 +00:00
|
|
|
|
.brief-sep { color: var(--border); font-size: 14px; }
|
|
|
|
|
|
.brief-count { font-size: 14px; color: var(--text3); }
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.brief-top-right {
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
gap: 4px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-icon-btn {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
width: 34px;
|
|
|
|
|
|
height: 34px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--text3);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: color 0.12s, background 0.12s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.brief-icon-btn:active { background: var(--surface); color: var(--text2); }
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── 現在再生中の記事情報(上部に固定配置) ──────── */
|
|
|
|
|
|
.brief-now-playing {
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
padding: 14px 20px 16px;
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
min-height: 80px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-article-meta {
|
|
|
|
|
|
font-size: 11px;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-weight: 600;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
color: var(--accent);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
letter-spacing: 0.08em;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
text-transform: uppercase;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
margin-bottom: 8px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-article-title {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 20px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--text);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
line-height: 1.4;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
display: -webkit-box;
|
|
|
|
|
|
-webkit-line-clamp: 3;
|
|
|
|
|
|
-webkit-box-orient: vertical;
|
|
|
|
|
|
overflow: hidden;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
margin-bottom: 6px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-article-source {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 13px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
color: var(--text3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── プレーヤー(波形 + 再生ボタンのみ)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;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── 波形キャンバス ─────────────────────────────── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.brief-wave-wrap {
|
|
|
|
|
|
width: 100%;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
height: 56px;
|
|
|
|
|
|
margin: 0 0 22px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
#waveCanvas {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── 再生ボタン ──────────────────────────────────── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.brief-play-btn {
|
|
|
|
|
|
width: 72px;
|
|
|
|
|
|
height: 72px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
color: #0D0D0D;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
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); }
|
2026-03-22 05:26:21 +00:00
|
|
|
|
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;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── コントロールバー ────────────────────────────── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.brief-controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
padding: 10px 24px 10px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
width: 100%;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
border-top: 1px solid var(--border);
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-ctrl-btn {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
height: 44px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
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;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
gap: 4px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
flex: 2;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.brief-speed-btn {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
padding: 7px 10px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--text3);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 12px;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── 記事リスト ───────────────────────────────────── */
|
2026-03-22 05:10:48 +00:00
|
|
|
|
.brief-list {
|
|
|
|
|
|
flex-shrink: 0;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
max-height: 42dvh;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
scrollbar-width: none;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
|
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
2026-03-22 05:10:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
.brief-list::-webkit-scrollbar { display: none; }
|
|
|
|
|
|
.brief-list-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 12px;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
padding: 12px 20px;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
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 {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 12px;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
color: var(--text3);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
width: 18px;
|
|
|
|
|
|
text-align: right;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
padding-top: 2px;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
2026-03-22 05:26:21 +00:00
|
|
|
|
.brief-list-num.active { color: var(--accent); font-weight: 600; }
|
2026-03-22 05:10:48 +00:00
|
|
|
|
.brief-list-info { flex: 1; min-width: 0; }
|
|
|
|
|
|
.brief-list-title {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 14px;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
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 {
|
2026-03-22 05:26:21 +00:00
|
|
|
|
font-size: 12px;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
color: var(--text3);
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
/* ── 音声エンジン表示 ───────────────────────────── */
|
2026-03-21 04:37:40 +00:00
|
|
|
|
.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); }
|
2026-03-22 05:26:21 +00:00
|
|
|
|
|
|
|
|
|
|
/* ── 設定パネル内の入力 ─────────────────────────── */
|
|
|
|
|
|
.settings-text-input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
padding: 9px 12px;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
transition: border-color 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.settings-text-input:focus { border-color: var(--accent); }
|
|
|
|
|
|
.settings-action-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
color: #0D0D0D;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.settings-field-label {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--text2);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
}
|
2026-03-21 04:37:40 +00:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 設定パネル -->
|
|
|
|
|
|
<aside class="settings-panel" id="settingsPanel" role="complementary">
|
|
|
|
|
|
<div class="settings-panel-header">
|
|
|
|
|
|
<span class="settings-panel-title">設定</span>
|
|
|
|
|
|
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
|
|
|
|
|
|
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="settings-panel-body">
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="settings-group-label">外観</div>
|
|
|
|
|
|
<div class="settings-item">
|
|
|
|
|
|
<div class="settings-item-label">テーマ</div>
|
|
|
|
|
|
<div class="theme-selector">
|
|
|
|
|
|
<button class="theme-btn" data-theme-val="dark">
|
|
|
|
|
|
<i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="theme-btn" data-theme-val="light">
|
|
|
|
|
|
<i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="theme-btn" data-theme-val="system">
|
|
|
|
|
|
<i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style="margin-top:20px">
|
|
|
|
|
|
<div class="settings-group-label">Posimai API Key</div>
|
|
|
|
|
|
<p class="settings-field-label">
|
|
|
|
|
|
設定すると Synology 経由でずんだもん音声が使えます(VOICEVOX 未設定時はブラウザ音声で再生)。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<input class="settings-text-input" id="apiKeyInput" type="password"
|
|
|
|
|
|
placeholder="posimai_api_key" autocomplete="off">
|
|
|
|
|
|
<button class="settings-action-btn" id="apiKeySave">保存</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
<div class="overlay" id="overlay" aria-hidden="true"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<main id="main-content">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- トップバー -->
|
|
|
|
|
|
<div class="brief-top">
|
|
|
|
|
|
<span class="brief-date" id="briefDate"></span>
|
|
|
|
|
|
<span class="brief-sep">·</span>
|
|
|
|
|
|
<span class="brief-count" id="briefCount">読み込み中...</span>
|
|
|
|
|
|
<div class="brief-top-right">
|
|
|
|
|
|
<span class="voice-badge" id="voiceBadge">
|
|
|
|
|
|
<i data-lucide="mic" style="width:10px;height:10px;stroke-width:2"></i>
|
|
|
|
|
|
<span id="voiceBadgeLabel">ブラウザ音声</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button class="brief-icon-btn" id="refreshBtn" aria-label="再読み込み">
|
|
|
|
|
|
<i data-lucide="refresh-cw" style="width:15px;height:15px;stroke-width:2"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="brief-icon-btn" id="settingsBtn" aria-label="設定">
|
|
|
|
|
|
<i data-lucide="settings" style="width:15px;height:15px;stroke-width:1.75"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
<!-- 現在再生中の記事情報(最上部) -->
|
|
|
|
|
|
<div class="brief-now-playing">
|
2026-03-21 04:37:40 +00:00
|
|
|
|
<div class="brief-article-meta" id="briefMeta">Posimai Brief</div>
|
|
|
|
|
|
<div class="brief-article-title" id="briefTitle">記事を読み込んでいます</div>
|
|
|
|
|
|
<div class="brief-article-source" id="briefSource"></div>
|
2026-03-22 05:26:21 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- プレーヤー(波形 + 再生ボタン) -->
|
|
|
|
|
|
<div class="brief-player" id="briefPlayer">
|
2026-03-21 04:37:40 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="brief-wave-wrap">
|
|
|
|
|
|
<canvas id="waveCanvas"></canvas>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="brief-progress" id="briefProgress"></div>
|
|
|
|
|
|
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-22 05:10:48 +00:00
|
|
|
|
<!-- 記事リスト -->
|
|
|
|
|
|
<div class="brief-list" id="briefList"></div>
|
|
|
|
|
|
|
2026-03-21 04:37:40 +00:00
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="toast" role="status" aria-live="polite"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
|
2026-03-22 05:26:21 +00:00
|
|
|
|
<script>
|
2026-03-21 04:37:40 +00:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Posimai Brief — 音声ニュースブリーフィング
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
const FEED_API = 'https://posimai-feed.vercel.app/api/feed';
|
|
|
|
|
|
const API_BASE = 'https://posimai-lab.tail72e846.ts.net/brain/api';
|
|
|
|
|
|
const TTS_API = API_BASE + '/tts';
|
|
|
|
|
|
const DAYS_JP = ['日','月','火','水','木','金','土'];
|
|
|
|
|
|
|
|
|
|
|
|
// ── 状態 ─────────────────────────────────────────────────────
|
2026-03-22 05:26:21 +00:00
|
|
|
|
let articles = [];
|
|
|
|
|
|
let speechQueue = [];
|
|
|
|
|
|
let currentIdx = 0;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
let isPlaying = false;
|
|
|
|
|
|
let speechRate = 1.0;
|
|
|
|
|
|
let audioCtx = null;
|
2026-03-22 05:26:21 +00:00
|
|
|
|
let currentSource = null;
|
|
|
|
|
|
let waveRAF = null;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
|
|
|
|
|
|
// ── 波形データ ────────────────────────────────────────────────
|
|
|
|
|
|
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 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 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 waveLoop() {
|
|
|
|
|
|
updateWave(isPlaying);
|
|
|
|
|
|
drawWave(isPlaying);
|
|
|
|
|
|
waveRAF = requestAnimationFrame(waveLoop);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 音声制御 ──────────────────────────────────────────────────
|
|
|
|
|
|
function getAudioCtx() {
|
|
|
|
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
|
|
|
|
return audioCtx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopAudio() {
|
|
|
|
|
|
if (currentSource) {
|
|
|
|
|
|
currentSource.onended = null;
|
|
|
|
|
|
try { currentSource.stop(); } catch(e) {}
|
|
|
|
|
|
currentSource = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.speechSynthesis.cancel();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function preprocessText(t) {
|
|
|
|
|
|
return (t || '')
|
|
|
|
|
|
.replace(/https?:\/\/\S+/g, '')
|
|
|
|
|
|
.replace(/[「」『』【】〔〕《》]/g, '')
|
|
|
|
|
|
.replace(/([。!?])([^\s])/g, '$1 $2')
|
|
|
|
|
|
.replace(/\s{2,}/g, ' ')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function tryVoicevox(text) {
|
|
|
|
|
|
const apiKey = localStorage.getItem('posimai-brief-apikey');
|
|
|
|
|
|
if (!apiKey) return false;
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
const ctx = getAudioCtx();
|
2026-03-21 04:37:40 +00:00
|
|
|
|
|
|
|
|
|
|
const res = await fetch(TTS_API, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
|
|
|
|
body: JSON.stringify({ text: preprocessText(text), speaker: 1 }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
2026-03-22 05:26:21 +00:00
|
|
|
|
if (!isPlaying) return true;
|
2026-03-21 04:37:40 +00:00
|
|
|
|
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
|
currentSource = ctx.createBufferSource();
|
|
|
|
|
|
currentSource.buffer = buf;
|
|
|
|
|
|
currentSource.connect(ctx.destination);
|
|
|
|
|
|
currentSource.onended = () => {
|
|
|
|
|
|
currentSource = null;
|
|
|
|
|
|
resolve(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
currentSource.start(0);
|
|
|
|
|
|
updateVoiceBadge(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|| voices.find(v => v.lang.startsWith('ja'))
|
|
|
|
|
|
|| null;
|
|
|
|
|
|
if (ja) utter.voice = ja;
|
|
|
|
|
|
utter.rate = speechRate;
|
|
|
|
|
|
utter.lang = 'ja-JP';
|
|
|
|
|
|
utter.onend = onEnd;
|
|
|
|
|
|
utter.onerror = onEnd;
|
|
|
|
|
|
speechSynthesis.speak(utter);
|
|
|
|
|
|
updateVoiceBadge(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function speak(text) {
|
|
|
|
|
|
if (!isPlaying) return;
|
|
|
|
|
|
stopAudio();
|
|
|
|
|
|
|
|
|
|
|
|
let voicevoxOk = false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await tryVoicevox(text);
|
|
|
|
|
|
voicevoxOk = result === true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
voicevoxOk = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isPlaying) return;
|
|
|
|
|
|
if (voicevoxOk) {
|
|
|
|
|
|
onSpeakEnd();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
speakBrowser(text, () => { if (isPlaying) onSpeakEnd(); });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onSpeakEnd() {
|
|
|
|
|
|
if (!isPlaying) return;
|
|
|
|
|
|
currentIdx++;
|
|
|
|
|
|
playNext();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function playNext() {
|
|
|
|
|
|
if (currentIdx >= speechQueue.length) {
|
|
|
|
|
|
isPlaying = false;
|
|
|
|
|
|
updatePlayBtn();
|
|
|
|
|
|
updateArticleDisplay(articles[articles.length - 1], articles.length - 1);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
updateArticleDisplay(
|
|
|
|
|
|
articles[Math.min(currentIdx, articles.length - 1)],
|
|
|
|
|
|
Math.min(currentIdx, articles.length - 1)
|
|
|
|
|
|
);
|
|
|
|
|
|
speak(speechQueue[currentIdx]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── UI 更新 ───────────────────────────────────────────────────
|
|
|
|
|
|
function updatePlayBtn() {
|
|
|
|
|
|
const btn = document.getElementById('playBtn');
|
|
|
|
|
|
const icon = isPlaying ? 'pause' : 'play';
|
|
|
|
|
|
btn.innerHTML = `<i data-lucide="${icon}" style="width:30px;height:30px;stroke-width:2;fill:#0D0D0D"></i>`;
|
|
|
|
|
|
btn.classList.toggle('playing', isPlaying);
|
|
|
|
|
|
lucide.createIcons();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
function esc(str) {
|
|
|
|
|
|
return (str || '')
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 04:37:40 +00:00
|
|
|
|
function updateArticleDisplay(a, idx) {
|
|
|
|
|
|
if (!a) return;
|
|
|
|
|
|
const total = articles.length;
|
|
|
|
|
|
const num = idx + 1;
|
|
|
|
|
|
document.getElementById('briefMeta').textContent = `${num} / ${total}`;
|
|
|
|
|
|
document.getElementById('briefTitle').textContent = a.title || '---';
|
|
|
|
|
|
document.getElementById('briefSource').textContent = a.source || '';
|
|
|
|
|
|
document.getElementById('briefProgress').textContent = '';
|
2026-03-22 05:10:48 +00:00
|
|
|
|
updateListHighlight(idx);
|
2026-03-21 04:37:40 +00:00
|
|
|
|
if ('mediaSession' in navigator) {
|
|
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
|
|
|
|
title: a.title || 'Posimai Brief',
|
|
|
|
|
|
artist: a.source || 'Posimai',
|
|
|
|
|
|
album: 'Daily Briefing',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 05:10:48 +00:00
|
|
|
|
function renderList() {
|
|
|
|
|
|
const list = document.getElementById('briefList');
|
|
|
|
|
|
list.innerHTML = '';
|
|
|
|
|
|
articles.forEach((a, i) => {
|
|
|
|
|
|
const item = document.createElement('div');
|
2026-03-22 05:26:21 +00:00
|
|
|
|
const isActive = i === currentIdx;
|
|
|
|
|
|
item.className = 'brief-list-item' + (isActive ? ' active' : '');
|
2026-03-22 05:10:48 +00:00
|
|
|
|
item.innerHTML = `
|
2026-03-22 05:26:21 +00:00
|
|
|
|
<span class="brief-list-num${isActive ? ' active' : ''}">${i + 1}</span>
|
2026-03-22 05:10:48 +00:00
|
|
|
|
<div class="brief-list-info">
|
2026-03-22 05:26:21 +00:00
|
|
|
|
<div class="brief-list-title${isActive ? ' active' : ''}">${esc(a.title)}</div>
|
|
|
|
|
|
${a.source ? `<div class="brief-list-source">${esc(a.source)}</div>` : ''}
|
2026-03-22 05:10:48 +00:00
|
|
|
|
</div>`;
|
|
|
|
|
|
item.addEventListener('click', () => {
|
|
|
|
|
|
const wasPlaying = isPlaying;
|
|
|
|
|
|
stopAudio();
|
2026-03-22 05:26:21 +00:00
|
|
|
|
isPlaying = false;
|
2026-03-22 05:10:48 +00:00
|
|
|
|
currentIdx = i;
|
|
|
|
|
|
updateArticleDisplay(articles[i], i);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
if (wasPlaying) {
|
|
|
|
|
|
isPlaying = true;
|
|
|
|
|
|
updatePlayBtn();
|
|
|
|
|
|
playNext();
|
|
|
|
|
|
}
|
2026-03-22 05:10:48 +00:00
|
|
|
|
});
|
|
|
|
|
|
list.appendChild(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateListHighlight(idx) {
|
|
|
|
|
|
document.querySelectorAll('.brief-list-item').forEach((el, i) => {
|
|
|
|
|
|
const active = i === idx;
|
|
|
|
|
|
el.classList.toggle('active', active);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
const numEl = el.querySelector('.brief-list-num');
|
|
|
|
|
|
const titleEl = el.querySelector('.brief-list-title');
|
|
|
|
|
|
if (numEl) numEl.classList.toggle('active', active);
|
|
|
|
|
|
if (titleEl) titleEl.classList.toggle('active', active);
|
2026-03-22 05:10:48 +00:00
|
|
|
|
if (active) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 04:37:40 +00:00
|
|
|
|
function updateVoiceBadge(isVoicevox) {
|
|
|
|
|
|
const badge = document.getElementById('voiceBadge');
|
|
|
|
|
|
const label = document.getElementById('voiceBadgeLabel');
|
|
|
|
|
|
label.textContent = isVoicevox ? 'ずんだもん' : 'ブラウザ音声';
|
|
|
|
|
|
badge.classList.toggle('voicevox', isVoicevox);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 再生・一時停止 ────────────────────────────────────────────
|
|
|
|
|
|
document.getElementById('playBtn').addEventListener('click', () => {
|
|
|
|
|
|
if (!articles.length) return;
|
|
|
|
|
|
isPlaying = !isPlaying;
|
|
|
|
|
|
updatePlayBtn();
|
|
|
|
|
|
if (isPlaying) {
|
|
|
|
|
|
if (currentIdx >= speechQueue.length) currentIdx = 0;
|
|
|
|
|
|
playNext();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopAudio();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── スキップ ─────────────────────────────────────────────────
|
|
|
|
|
|
function skip(dir) {
|
|
|
|
|
|
const wasPlaying = isPlaying;
|
|
|
|
|
|
stopAudio();
|
2026-03-22 05:26:21 +00:00
|
|
|
|
isPlaying = false;
|
|
|
|
|
|
currentIdx = Math.max(0, Math.min(articles.length - 1, currentIdx + dir));
|
|
|
|
|
|
updateArticleDisplay(articles[currentIdx], currentIdx);
|
|
|
|
|
|
if (wasPlaying) {
|
|
|
|
|
|
isPlaying = true;
|
|
|
|
|
|
updatePlayBtn();
|
|
|
|
|
|
playNext();
|
|
|
|
|
|
}
|
2026-03-21 04:37:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('prevBtn').addEventListener('click', () => skip(-1));
|
|
|
|
|
|
document.getElementById('nextBtn').addEventListener('click', () => skip(1));
|
|
|
|
|
|
|
2026-03-22 05:26:21 +00:00
|
|
|
|
// MediaSession
|
2026-03-21 04:37:40 +00:00
|
|
|
|
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('previoustrack', () => skip(-1));
|
|
|
|
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => skip(1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 速度変更 ─────────────────────────────────────────────────
|
|
|
|
|
|
document.querySelectorAll('.brief-speed-btn').forEach(btn => {
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── API キー保存 ──────────────────────────────────────────────
|
|
|
|
|
|
document.getElementById('apiKeyInput').value = localStorage.getItem('posimai-brief-apikey') || '';
|
|
|
|
|
|
document.getElementById('apiKeySave').addEventListener('click', () => {
|
|
|
|
|
|
const v = document.getElementById('apiKeyInput').value.trim();
|
|
|
|
|
|
localStorage.setItem('posimai-brief-apikey', v);
|
2026-03-22 05:26:21 +00:00
|
|
|
|
if (typeof showToast === 'function') showToast('保存しました');
|
2026-03-21 04:37:40 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 日付表示 ─────────────────────────────────────────────────
|
|
|
|
|
|
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})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── フィード読み込み ──────────────────────────────────────────
|
|
|
|
|
|
async function loadFeed() {
|
|
|
|
|
|
document.getElementById('briefTitle').textContent = '記事を読み込んでいます';
|
|
|
|
|
|
document.getElementById('briefSource').textContent = '';
|
2026-03-22 05:26:21 +00:00
|
|
|
|
document.getElementById('briefMeta').textContent = 'Posimai Brief';
|
2026-03-21 04:37:40 +00:00
|
|
|
|
document.getElementById('briefCount').textContent = '...';
|
2026-03-22 05:26:21 +00:00
|
|
|
|
document.getElementById('briefList').innerHTML = '';
|
2026-03-21 04:37:40 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(FEED_API, { cache: 'no-store' });
|
|
|
|
|
|
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;
|
|
|
|
|
|
speechQueue = [];
|
|
|
|
|
|
currentIdx = 0;
|
|
|
|
|
|
|
|
|
|
|
|
list.forEach((a, i) => {
|
|
|
|
|
|
const prefix = i === 0 ? '最初のニュースです。' : '続いて。';
|
|
|
|
|
|
speechQueue.push(`${prefix}${a.source || ''}より。${a.title || ''}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
speechQueue.push('本日のブリーフィングは以上です。');
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('briefCount').textContent = `${list.length}件`;
|
|
|
|
|
|
updateArticleDisplay(list[0], 0);
|
2026-03-22 05:10:48 +00:00
|
|
|
|
renderList();
|
2026-03-21 04:37:40 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
document.getElementById('briefTitle').textContent = '記事の取得に失敗しました';
|
|
|
|
|
|
document.getElementById('briefSource').textContent = 'ネットワークを確認してください';
|
2026-03-22 05:26:21 +00:00
|
|
|
|
document.getElementById('briefMeta').textContent = 'ERROR';
|
2026-03-21 04:37:40 +00:00
|
|
|
|
document.getElementById('briefCount').textContent = 'エラー';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 再読み込みボタン ──────────────────────────────────────────
|
|
|
|
|
|
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
|
|
|
|
if (isPlaying) { isPlaying = false; stopAudio(); updatePlayBtn(); }
|
|
|
|
|
|
loadFeed();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── SW 登録 ──────────────────────────────────────────────────
|
|
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
|
|
|
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 初期化 ───────────────────────────────────────────────────
|
|
|
|
|
|
updateDate();
|
|
|
|
|
|
waveLoop();
|
|
|
|
|
|
loadFeed();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|