diff --git a/.env.example b/.env.example index d4bd34b..ad1af8a 100644 --- a/.env.example +++ b/.env.example @@ -57,12 +57,21 @@ BETTER_AUTH_SECRET= # 本番: https://tecnova-api..workers.dev BETTER_AUTH_URL= -# 管理画面・受付アプリなど、API に cross-origin で来るフロントのオリジン許可リスト。 +# 管理画面・受付アプリ・サイネージなど、API に cross-origin で来るフロントのオリジン許可リスト。 # カンマ区切り文字列。CORS と Better Auth の trustedOrigins の両方で参照される。 -# 開発: http://localhost:3000,http://localhost:3001 -# 本番: https://,https:// +# 開発: http://localhost:3000,http://localhost:3001,http://localhost:3002 +# 本番: https://,https://,https:// TRUSTED_ORIGINS= +# ----------------------------------------------------------------------------- +# YouTube Data API (apps/signage 動画プレイリスト) +# ----------------------------------------------------------------------------- +# サイネージの GET /api/signage/playlist が playlistItems.list を叩くための APIキーと +# プレイリストID。APIキーは「YouTube Data API v3 限定」の制限を付与する(リファラ制限は +# 付けない=サーバ側呼び出し)。本番は Wrangler Secrets、ローカルは apps/api/.dev.vars。 +YOUTUBE_API_KEY= +YOUTUBE_PLAYLIST_ID= + # ----------------------------------------------------------------------------- # Frontend (Next.js apps) # ----------------------------------------------------------------------------- diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d39283b..4f9bc4d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import { authRoute } from './routes/auth'; import { checkinRoute } from './routes/checkin'; import { healthRoute } from './routes/health'; import { preRegistrationsRoute } from './routes/pre-registrations'; +import { signageRoute } from './routes/signage'; import type { AppEnv } from './types'; const app = new Hono(); @@ -26,6 +27,7 @@ app.use('/checkin/*', requireAuthenticatedMentor); app.route('/api', adminRoute); app.route('/api/pre-registrations', preRegistrationsRoute); +app.route('/api/signage', signageRoute); app.route('/checkin', checkinRoute); app.route('/', healthRoute); diff --git a/apps/api/src/lib/signage.ts b/apps/api/src/lib/signage.ts new file mode 100644 index 0000000..d9aacab --- /dev/null +++ b/apps/api/src/lib/signage.ts @@ -0,0 +1,70 @@ +import type * as schema from '@tecnova/db'; +import { events, sessions } from '@tecnova/db'; +import type { + SignagePlaylistResponse, + SignagePreviousSummaryResponse, +} from '@tecnova/shared/schemas'; +import { toJstDateString } from '@tecnova/shared/venue-schedule'; +import { summarizeStays } from '@tecnova/shared/visit-summary'; +import { fetchPlaylistVideos, type YouTubePlaylistVideo } from '@tecnova/shared/youtube'; +import { desc, eq, lt } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import type { Bindings } from '../types'; + +type Db = DrizzleD1Database; + +// プレイリストはサーバ側で数分キャッシュする。Data API のクォータ節約と、プレイリスト +// 更新の反映遅延(数分)は許容(spec §5.1)。Workers がリサイクルされたら自然に再取得。 +const CACHE_TTL_MS = 5 * 60_000; + +let cache: { items: YouTubePlaylistVideo[]; expiresAt: number } | null = null; + +export const fetchSignagePlaylist = async (env: Bindings): Promise => { + const now = Date.now(); + if (!cache || cache.expiresAt <= now) { + const items = await fetchPlaylistVideos(env.YOUTUBE_API_KEY, env.YOUTUBE_PLAYLIST_ID); + cache = { items, expiresAt: now + CACHE_TTL_MS }; + } + return { items: cache.items, refreshAt: new Date(cache.expiresAt).toISOString() }; +}; + +// 前回開催日(JST の今日より前で最新の event)の来場・滞在データを集計して返す。 +// events.date は TEXT 'YYYY-MM-DD' なので辞書順比較=日付順で「今日より前の最新」を取れる。 +// 返すのは集計値のみ(PII なし)。前回開催が無ければ previous=null。 +export const fetchPreviousEventSummary = async ( + db: Db, +): Promise => { + const today = toJstDateString(new Date()); + const [event] = await db + .select({ id: events.id, date: events.date }) + .from(events) + .where(lt(events.date, today)) + .orderBy(desc(events.date)) + .limit(1); + if (!event) return { previous: null }; + + const rows = await db + .select({ + participantId: sessions.participantId, + checkedInAt: sessions.checkedInAt, + checkedOutAt: sessions.checkedOutAt, + }) + .from(sessions) + .where(eq(sessions.eventId, event.id)); + + // 退館→再入館で同一人物が複数行になるため、participantId を渡してユニーク人数で集計する。 + const summary = summarizeStays( + rows.map((r) => ({ + participantId: r.participantId, + checkedInAt: r.checkedInAt.getTime(), + checkedOutAt: r.checkedOutAt ? r.checkedOutAt.getTime() : null, + })), + ); + return { + previous: { + date: event.date, + participantCount: summary.count, + averageStayMinutes: summary.averageStayMinutes, + }, + }; +}; diff --git a/apps/api/src/routes/signage.ts b/apps/api/src/routes/signage.ts new file mode 100644 index 0000000..d0cac3e --- /dev/null +++ b/apps/api/src/routes/signage.ts @@ -0,0 +1,30 @@ +import { events } from '@tecnova/db'; +import { count } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { fetchPreviousEventSummary, fetchSignagePlaylist } from '../lib/signage'; +import { createDb } from '../middleware/auth'; +import type { AppEnv } from '../types'; + +// サイネージ用エンドポイント。requireAuthenticatedMentor は index.ts で /api/* に適用済み +// なので、ここでは付けない(メンター認証済みの信頼端末からのみ叩かれる)。 +export const signageRoute = new Hono(); + +// 動画プレイリスト(順序付き videoId 列)。取得失敗(APIキー未設定・YouTube エラー等)は +// throw され apiErrorHandler が 500 化し、クライアントは §5.4 のフォールバック videoId に倒れる。 +signageRoute.get('/playlist', async (c) => c.json(await fetchSignagePlaylist(c.env))); + +// 前回開催日の来場・滞在データ(集計のみ・PII なし)。 +signageRoute.get('/previous-summary', async (c) => + c.json(await fetchPreviousEventSummary(createDb(c.env))), +); + +// 基盤システムの稼働確認。軽量な DB 到達クエリを1本投げ、成功なら ok を返す +// (失敗時は apiErrorHandler が 500 化し、サイネージ側で「障害」表示に倒れる)。 +// /health(公開・root マウント)は CORS 対象外で別オリジンのサイネージから叩けないため、 +// CORS 済みの /api 配下に置く。 +signageRoute.get('/health', async (c) => { + const db = createDb(c.env); + // count() は1行に畳まれるので limit は不要。到達できれば ok。 + await db.select({ n: count() }).from(events); + return c.json({ status: 'ok', time: new Date().toISOString() }); +}); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index a720467..8cabab5 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -9,6 +9,9 @@ export type Bindings = { BETTER_AUTH_SECRET: string; BETTER_AUTH_URL: string; TRUSTED_ORIGINS: string; + // サイネージの動画プレイリスト取得(YouTube Data API v3・APIキーのみ)。 + YOUTUBE_API_KEY: string; + YOUTUBE_PLAYLIST_ID: string; }; export interface AuthUser { diff --git a/apps/signage/.gitignore b/apps/signage/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/signage/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/signage/AGENTS.md b/apps/signage/AGENTS.md new file mode 100644 index 0000000..57b2268 --- /dev/null +++ b/apps/signage/AGENTS.md @@ -0,0 +1,3 @@ +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. diff --git a/apps/signage/CLAUDE.md b/apps/signage/CLAUDE.md new file mode 100644 index 0000000..d2be720 --- /dev/null +++ b/apps/signage/CLAUDE.md @@ -0,0 +1,16 @@ +@AGENTS.md + +# signage(会場サイネージ / 大型モニター・キオスク) + +- **Next.js 16 / React 19**。App Router の API がトレーニングデータと乖離しているため、実装前に `node_modules/next/dist/docs/` を確認すること。 +- **dev ポート**: `3002`(`next dev --port 3002`)。api は `8787`、checkin は `3000`、admin は `3001`。 +- **認証あり**: checkin/admin と同じメンター・ホワイトリスト(`MeProvider`/`auth-client`)。運用は**テクノバ共有の管理用 Google アカウント**で1回ログイン(セッション既定7日)。`useMe` はツリー内に `MeProvider` 必須。 +- **データ**: 認証付き `GET /api/sessions/today` を再利用(稼働判定・在館数)。ターム別チェックイン数は `sessions[].term` から算出し、**現タームに当日チェックインが入った時点で稼働開始**(ターム終了まで sticky)。 +- **レイアウト**: 配信(ブロードキャスト)風(`BroadcastFrame`)。上=`StageHeader`(ワードマーク・ステータス・JST時計)/中=`VideoStage`(縮小動画パネル。休憩・待機は `BreakOverlay`/`IdleOverlay` をパネル上にクロスフェード)+右レーン `ChimeRail`(**チャイムの役割**=次チャイムまでのカウントダウンリング・ターム・サイクル、idle 時は本日のスケジュール)/下=`InfoTicker`(動画タイトル・来場・にぎわい・主催/共催・tec-nova/tecnova-platform 紹介を巡回)。状態機械(activity/break/idle)と稼働判定は従来どおり `page.tsx` で算出し frame に渡すだけ。世界観は checkin に統一(`from-sky-50 to-white`・`motion` + `useReducedMotion`・`rounded-2xl`/`shadow-sm` カード)。にぎわいは `@tecnova/shared/attendance-level` の純粋ロジック由来。 +- **時刻ロジック**: `@tecnova/shared/activity-cycle`(50分活動/10分休憩・チャイム時刻)。時刻ソースは `src/lib/now.ts` の `getNow()`(優先順位: デバッグ擬似時計 → `?now=ISO` アンカー → 実時刻)。 +- **プレビュー/デバッグ**: `?debug=1` を付けたときだけ画面下部に操作バー(`DebugPanel`)が出る。本番壁面は `?debug=1` 無しで起動するので影響ゼロ(`debugEnabled=false` で全分岐が短絡=従来挙動と完全同値)。擬似時計(ジャンプ/一時停止/×1×30×120)・稼働強制(チェックインデータ無しで活動/休憩を再現)・手動チャイムで、実時刻を待たずに全状態と各遷移+チャイムを検証できる。下部インフォメーション(`InfoTicker`)には `?debug=1` のとき ◀▶ の手動送りが出る。ストアは `now.ts` に同居し `useSyncExternalStore` で購読。チャイムスケジューラは `jumpEpoch` で前方ジャンプ時の過去チャイム一斉発火を抑止する。 +- **動画**: 縮小した動画パネル(`VideoStage`)に YouTube IFrame Player API の自前キューを**常時マウント**(`ENDED`/`onError` で次 videoId へ `loadVideoById`、活動フェーズのみ再生・他は pause)。再生順は YouTube のプレイリストを `GET /api/signage/playlist`(YouTube Data API・Worker キャッシュ)が videoId+タイトル列にして返し、`usePlaylist` が保持/`useYoutubePlayer` の `onVideoChange` で現在トラックを通知(下部インフォの動画タイトル用)。フォールバックは `src/config/playlist.ts` の `FALLBACK_VIDEO_IDS`。**広告は埋め込み側で消せない**(仕様 §5.3)。 +- **音声**: 無音/音ありのグローバルトグルのみ(既定=無音・localStorage)。BGM は **OS側 Spotify**(アプリ非統合)。チャイムは Web Audio 合成で独立。 +- **キオスク**: 横向き・フルスクリーン。起動「タップして開始」で**チャイム解放・全画面・wake lock**(+音ありモード時のみ動画 unMute)。ミュート動画はタップ前から再生。本番は Chromium を `--kiosk` 等で起動。 +- **必須 env**: `NEXT_PUBLIC_API_URL`(未設定時 `http://localhost:8787`)。API 側 `TRUSTED_ORIGINS` にサイネージ origin(dev: `http://localhost:3002`、本番ドメイン)、`YOUTUBE_API_KEY`/`YOUTUBE_PLAYLIST_ID` を登録すること。 +- 新しい `@tecnova/*` パッケージを使うときは `next.config.ts` の `transpilePackages` に追加。 diff --git a/apps/signage/components.json b/apps/signage/components.json new file mode 100644 index 0000000..8962ad6 --- /dev/null +++ b/apps/signage/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-maia", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "tabler", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "utils": "@tecnova/ui/lib/utils", + "ui": "@tecnova/ui/components" + }, + "rtl": false, + "menuColor": "default-translucent", + "menuAccent": "subtle" +} diff --git a/apps/signage/next.config.ts b/apps/signage/next.config.ts new file mode 100644 index 0000000..8c125ca --- /dev/null +++ b/apps/signage/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + // モノレポ内 workspace パッケージを Next の transpile 対象にする + transpilePackages: ['@tecnova/shared', '@tecnova/ui'], +}; + +export default nextConfig; diff --git a/apps/signage/package.json b/apps/signage/package.json new file mode 100644 index 0000000..595ab91 --- /dev/null +++ b/apps/signage/package.json @@ -0,0 +1,31 @@ +{ + "name": "signage", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@tabler/icons-react": "^3.42.0", + "@tecnova/shared": "workspace:*", + "@tecnova/ui": "workspace:*", + "better-auth": "^1.6.9", + "motion": "^12.40.0", + "next": "16.2.4", + "qrcode.react": "^4.2.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/youtube": "^0.1.0", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/signage/postcss.config.mjs b/apps/signage/postcss.config.mjs new file mode 100644 index 0000000..78aa4a0 --- /dev/null +++ b/apps/signage/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@tecnova/ui/postcss.config'; diff --git a/apps/signage/public/logo_tecnova.png b/apps/signage/public/logo_tecnova.png new file mode 100644 index 0000000..c1b7689 Binary files /dev/null and b/apps/signage/public/logo_tecnova.png differ diff --git a/apps/signage/src/app/layout.tsx b/apps/signage/src/app/layout.tsx new file mode 100644 index 0000000..8ce352d --- /dev/null +++ b/apps/signage/src/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata, Viewport } from 'next'; +import { LINE_Seed_JP } from 'next/font/google'; +import '@tecnova/ui/globals.css'; +import { cn } from '@tecnova/ui/lib/utils'; +import { AppShell } from '@/components/app-shell'; + +const fontSans = LINE_Seed_JP({ + variable: '--font-sans', + weight: ['100', '400', '700', '800'], + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'テクノバ サイネージ', + // iOS Safari に PWA 起動を伝える。Android/Chromium 用の Web マニフェスト + // (app/manifest.ts)と両方必要。 + appleWebApp: { capable: true, title: 'サイネージ', statusBarStyle: 'black-translucent' }, +}; + +export const viewport: Viewport = { + // 配信レイアウトの地(sky-50)に合わせる。 + themeColor: '#f0f9ff', + // 大型モニターのキオスク表示。ピンチズーム無効で誤操作を防ぐ。 + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/apps/signage/src/app/login/page.tsx b/apps/signage/src/app/login/page.tsx new file mode 100644 index 0000000..8b5b9ce --- /dev/null +++ b/apps/signage/src/app/login/page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert'; +import { Button } from '@tecnova/ui/components/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@tecnova/ui/components/card'; +import Image from 'next/image'; +import { useState } from 'react'; +import { Reveal } from '@/components/reveal'; +import { authClient } from '@/lib/auth-client'; + +export default function LoginPage() { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const signIn = async () => { + setBusy(true); + setError(null); + try { + // callbackURL は絶対URL(フロントのオリジンに戻す)。相対だと API オリジンに着地する。 + const redirect = `${window.location.origin}/`; + await authClient.signIn.social({ + provider: 'google', + callbackURL: redirect, + errorCallbackURL: redirect, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + } + }; + + return ( +
+ + + +
+ tec-nova ながさき + サイネージ表示 +
+
+ {error && ( + + + ログインエラー + {error} + + + )} + + +

+ 許可リストに登録されたアカウントのみ利用できます +

+
+
+
+
+ ); +} diff --git a/apps/signage/src/app/manifest.ts b/apps/signage/src/app/manifest.ts new file mode 100644 index 0000000..9b800dd --- /dev/null +++ b/apps/signage/src/app/manifest.ts @@ -0,0 +1,18 @@ +import type { MetadataRoute } from 'next'; + +// 壁掛けモニター向け。display:'fullscreen' + orientation:'landscape' が最も強いキオスク表示。 +// アイコンは省略(--kiosk 起動では PWA インストール不要)。 +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'テクノバながさき サイネージ', + short_name: 'サイネージ', + description: 'テクノバながさきの会場サイネージ表示', + start_url: '/', + display: 'fullscreen', + orientation: 'landscape', + // 配信レイアウトの明るい地に合わせる(スプラッシュ=sky-50、テーマ=sky-500)。 + background_color: '#f0f9ff', + theme_color: '#0ea5e9', + lang: 'ja', + }; +} diff --git a/apps/signage/src/app/page.tsx b/apps/signage/src/app/page.tsx new file mode 100644 index 0000000..9309ad8 --- /dev/null +++ b/apps/signage/src/app/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { + type ChimeEvent, + classifyCycleMoment, + msUntilNextBoundary, +} from '@tecnova/shared/activity-cycle'; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { BroadcastFrame } from '@/components/broadcast-frame'; +import { DebugPanel } from '@/components/debug-panel'; +import { TapToStart } from '@/components/tap-to-start'; +import { ensureAudioRunning, playChime, resumeAudio } from '@/lib/chimes'; +import { + enableDebug, + getDebugServerSnapshot, + getDebugSnapshot, + getNow, + isDebugQueryEnabled, + subscribeDebug, +} from '@/lib/now'; +import { useChimeScheduler } from '@/lib/use-chime-scheduler'; +import { useMute } from '@/lib/use-mute'; +import { useNow } from '@/lib/use-now'; +import { usePlaylist } from '@/lib/use-playlist'; +import { useSignageData } from '@/lib/use-signage-data'; +import { useWakeLock } from '@/lib/use-wake-lock'; + +export default function SignagePage() { + const [started, setStarted] = useState(false); + // ?debug=1 のときだけ有効なプレビュー用ストア。本番では全フィールド既定値のまま。 + const debug = useSyncExternalStore(subscribeDebug, getDebugSnapshot, getDebugServerSnapshot); + const now = useNow(debug.debugEnabled ? 250 : 1000); + const data = useSignageData(); + const tracks = usePlaylist(); + // identity を安定させ、プレーヤーの救済 effect(deps: videoIds)が毎レンダ走るのを避ける。 + const videoIds = useMemo(() => tracks.map((t) => t.videoId), [tracks]); + const { muted, toggle } = useMute(); + // いま再生中トラック(インフォメーションの動画タイトル表示用)。 + const [currentIndex, setCurrentIndex] = useState(0); + const currentTrack = tracks[currentIndex] ?? null; + const moment = classifyCycleMoment(now); + + // ?debug=1 ならマウント後にデバッグストアを有効化する(SSR/hydration は素通り)。 + useEffect(() => { + if (isDebugQueryEnabled()) enableDebug(); + }, []); + + // デバッグ時は forcedActiveTerms を OR して、実チェックインデータ無しでも稼働を再現する + // (debugEnabled=false なら短絡し本番挙動と完全同値)。 + const isTermActive = (term: 'morning' | 'afternoon' | 'evening') => + (debug.debugEnabled && debug.forcedActiveTerms.includes(term)) || data.termCounts[term] > 0; + + // 現タームが稼働中(初回チェックイン済み)なら moment.phase、未稼働/ターム外は idle。 + const active = moment.term !== null && isTermActive(moment.term); + const phase = active ? moment.phase : 'idle'; + + useWakeLock(started); + + const onChime = useCallback((e: ChimeEvent) => { + playChime(e.kind); + }, []); + useChimeScheduler({ + enabled: started, + isTermActive, + onChime, + getNow, + jumpEpoch: debug.jumpEpoch, + }); + + // タブ復帰時に AudioContext が suspended に戻っていれば再開する(spec §6)。 + useEffect(() => { + if (!started) return; + const onVisible = (): void => { + if (document.visibilityState === 'visible') void ensureAudioRunning(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [started]); + + const handleStart = async (): Promise => { + await resumeAudio(); + try { + await document.documentElement.requestFullscreen?.(); + } catch { + // 全画面はベストエフォート(dev では拒否されうる)。 + } + setStarted(true); + }; + + // フェーズ終端までの秒(活動→休憩 / 休憩→再開)。休憩スライドのカウントダウンに使う。 + const phaseSecondsLeft = + moment.phaseEndsAt === null + ? null + : Math.ceil((moment.phaseEndsAt.getTime() - now.getTime()) / 1000); + + // idle の理由を区別:ターム内・未稼働なら「まもなく開始」、ターム外なら次タームの開始時刻。 + const inUnstartedTerm = moment.term !== null && !active; + // 次の活動開始(次境界=次タームの resume)はターム外のときだけ算出する。 + const msNext = moment.term === null ? msUntilNextBoundary(now) : null; + const nextStartAt = msNext === null ? null : new Date(now.getTime() + msNext); + + return ( +
+ + + {!started && } + + {/* ?debug=1 のときだけプレビュー操作バーを出す(本番壁面では出ない)。 */} + {debug.debugEnabled && ( + { + void resumeAudio(); + setStarted(true); + }} + /> + )} +
+ ); +} diff --git a/apps/signage/src/components/animated-time.tsx b/apps/signage/src/components/animated-time.tsx new file mode 100644 index 0000000..8476c15 --- /dev/null +++ b/apps/signage/src/components/animated-time.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { mmss } from '@/lib/time'; + +// 桁単位の縦ロール(odometer)。秒は減る方向なので、新字が上から入り旧字が下へ抜ける。 +// transform/opacity のみで GPU 合成に乗せ、reduced-motion / value=null では素の文字を返す。 +// フリップ(3D回転)は大型壁面で派手・安っぽくなるため採らず、reveal/live-dot と同じ +// 「静かに動く」所作に揃える。 +const ROLL_DURATION = 0.28; + +// 1桁ぶんのスロット。tabular-nums 前提なので 1ch 幅で安定する。key=値 なので +// 「同じ数字のあいだ」は再アニメせず静止=視線が落ち着く(毎秒は一の位だけ動く)。 +function RollDigit({ char }: { char: string }) { + if (char === ':') { + return :; + } + return ( + + + + {char} + + + + ); +} + +interface Props { + value: number | null; // 残り秒。内部で mmss して M:SS を桁スロットへ分解。 + className?: string; + placeholder?: string; +} + +// — countdown-ring / break-overlay 共用のドロップイン。 +export function AnimatedTime({ value, className, placeholder = '--:--' }: Props) { + const reduced = useReducedMotion(); + const text = value === null ? placeholder : mmss(value); + + // reduced-motion / 値なし → 現行挙動と完全等価(アニメなし)。 + if (reduced || value === null) { + return {text}; + } + + // 'M:SS' を1文字ずつスロット化。右詰めの『種別+位置』を key にすることで、 + // 可変長(9:59↔10:00・分のロールオーバー)でも桁数増減に追従する。 + const [mm, ss] = text.split(':'); + const slots = [ + ...mm.split('').map((c, i) => ({ id: `m${mm.length - i}`, char: c })), + { id: 'colon', char: ':' }, + ...ss.split('').map((c, i) => ({ id: `s${i}`, char: c })), + ]; + + return ( + + {slots.map((slot) => ( + + ))} + + ); +} diff --git a/apps/signage/src/components/app-shell.tsx b/apps/signage/src/components/app-shell.tsx new file mode 100644 index 0000000..5400399 --- /dev/null +++ b/apps/signage/src/components/app-shell.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { MeProvider } from '@tecnova/ui/components/me-provider'; +import { usePathname } from 'next/navigation'; + +// サイネージは全画面表示なので checkin のようなヘッダ chrome は持たず、MeProvider だけで包む。 +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + // /login は認証ゲートの外(401 時の遷移先)。 + if (pathname.startsWith('/login')) { + return <>{children}; + } + return ( + + {children} + + ); +} diff --git a/apps/signage/src/components/break-overlay.tsx b/apps/signage/src/components/break-overlay.tsx new file mode 100644 index 0000000..d25b269 --- /dev/null +++ b/apps/signage/src/components/break-overlay.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { AnimatedTime } from './animated-time'; + +// 休憩中に動画パネルへ重ねる明るいスライド(配信の「休憩カード」)。 +export function BreakOverlay({ + secondsToResume, + present, +}: { + secondsToResume: number | null; + present: number; +}) { + return ( +
+ + 休憩中 + +

つぎの活動まで

+ +

+ すこし やすんで、また あそぼう! +

+ {present > 0 && ( +

+ いま {present} 人が あそびちゅう +

+ )} +
+ ); +} diff --git a/apps/signage/src/components/broadcast-frame.tsx b/apps/signage/src/components/broadcast-frame.tsx new file mode 100644 index 0000000..27bb085 --- /dev/null +++ b/apps/signage/src/components/broadcast-frame.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { CycleMoment, CyclePhase } from '@tecnova/shared/activity-cycle'; +import type { SignagePlaylistItem } from '@tecnova/shared/schemas'; +import { airStatus } from '@/lib/broadcast'; +import type { SignageData } from '@/lib/use-signage-data'; +import { ChimeRail } from './chime-rail'; +import { InfoTicker } from './info-ticker'; +import { Reveal } from './reveal'; +import { StageHeader } from './stage-header'; +import { VideoStage } from './video-stage'; + +interface Props { + phase: CyclePhase; + moment: CycleMoment; + now: Date; + data: SignageData; + videoIds: string[]; + currentTrack: SignagePlaylistItem | null; + muted: boolean; + started: boolean; + onToggleMute: () => void; + onVideoChange: (index: number) => void; + soon: boolean; + nextStartAt: Date | null; + phaseSecondsLeft: number | null; + debug?: boolean; +} + +// 配信レイアウト全体。上=ヘッダ、中=動画パネル+チャイムレーン、下=巡回インフォメーション。 +export function BroadcastFrame({ + phase, + moment, + now, + data, + videoIds, + currentTrack, + muted, + started, + onToggleMute, + onVideoChange, + soon, + nextStartAt, + phaseSecondsLeft, + debug, +}: Props) { + const status = airStatus({ phase, soon, hasNext: nextStartAt !== null }); + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/apps/signage/src/components/chime-rail.tsx b/apps/signage/src/components/chime-rail.tsx new file mode 100644 index 0000000..a2170e3 --- /dev/null +++ b/apps/signage/src/components/chime-rail.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { + ACTIVITY_MINUTES, + BREAK_MINUTES, + type ChimeKind, + type CycleMoment, + type CyclePhase, + cycleChimeEventsForDay, +} from '@tecnova/shared/activity-cycle'; +import { TERM_LABELS, TERMS, type TermId } from '@tecnova/shared/venue-schedule'; +import { cn } from '@tecnova/ui/lib/utils'; +import type { ReactNode } from 'react'; +import { jstHm } from '@/lib/time'; +import { CountdownRing } from './countdown-ring'; +import { TermTimeline } from './term-timeline'; +import { UpNext } from './up-next'; + +// ターム別アクセント(TermBadge と同系統の色)。 +const TERM_TONE: Record = { + morning: { heading: 'text-sky-700', range: 'text-sky-600' }, + afternoon: { heading: 'text-amber-700', range: 'text-amber-600' }, + evening: { heading: 'text-violet-700', range: 'text-violet-600' }, +}; + +const KIND_LABEL: Record = { + resume: '活動再開', + break: '休憩', + 'term-end': 'ターム終了', +}; + +const MARKER_CLASS: Record<'emerald' | 'amber' | 'sky', string> = { + emerald: 'bg-emerald-500', + amber: 'bg-amber-500', + sky: 'bg-sky-500', +}; + +function RailCard({ className, children }: { className?: string; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +interface Props { + phase: CyclePhase; + moment: CycleMoment; + now: Date; + soon: boolean; // ターム内・未稼働(まもなく開始) + nextStartAt: Date | null; // 次タームの開始時刻(ターム外のみ) +} + +// 右レーン=チャイムの役割を持つゾーン。活動/休憩中は次チャイムまでのカウントダウン+ +// 次の予定+ターム進行タイムライン、idle 中は本日のスケジュールを出す。 +export function ChimeRail({ phase, moment, now, soon, nextStartAt }: Props) { + const isRunning = phase === 'activity' || phase === 'break'; + + if (isRunning && moment.term) { + const def = TERMS.find((t) => t.id === moment.term); + const tone = TERM_TONE[moment.term]; + const remaining = moment.phaseEndsAt + ? Math.ceil((moment.phaseEndsAt.getTime() - now.getTime()) / 1000) + : null; + const total = (phase === 'activity' ? ACTIVITY_MINUTES : BREAK_MINUTES) * 60; + const next = cycleChimeEventsForDay(now) + .filter((e) => e.at.getTime() > now.getTime()) + .sort((a, b) => a.at.getTime() - b.at.getTime())[0]; + const ringTone = phase === 'activity' ? 'emerald' : next?.kind === 'term-end' ? 'amber' : 'sky'; + const ringLabel = + phase === 'activity' + ? 'つぎの休憩まで' + : next?.kind === 'term-end' + ? 'このタームの終わりまで' + : '再開まで'; + const targetLabel = next ? `${jstHm(next.at)} に${KIND_LABEL[next.kind]}` : null; + + return ( +
+ +

ただいまの部

+

+ {TERM_LABELS[moment.term]}の部 +

+ {def && ( +

+ {def.start} – {def.end} +

+ )} +
+ + +
+ + +
+ +
+
+ ); + } + + // idle(ターム外 / 未稼働):ヘッドライン + 本日のスケジュール。 + const headline = soon ? 'まもなく はじまるよ' : nextStartAt ? 'つぎの活動は' : '本日は おしまい'; + return ( +
+ +

+ {headline} +

+ {!soon && nextStartAt && ( +

+ {jstHm(nextStartAt)} + から +

+ )} +
+ +

+ 本日のスケジュール +

+ {TERMS.map((t) => { + const isCurrent = moment.term === t.id; + return ( +
+ + {TERM_LABELS[t.id]}の部 + + + {t.start}–{t.end} + +
+ ); + })} +
+
+ ); +} diff --git a/apps/signage/src/components/countdown-ring.tsx b/apps/signage/src/components/countdown-ring.tsx new file mode 100644 index 0000000..b52c48f --- /dev/null +++ b/apps/signage/src/components/countdown-ring.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { mmss } from '@/lib/time'; +import { AnimatedTime } from './animated-time'; + +type Tone = 'emerald' | 'amber' | 'sky'; + +const TONE_STROKE: Record = { + emerald: 'text-emerald-500', + amber: 'text-amber-500', + sky: 'text-sky-500', +}; + +interface Props { + remaining: number | null; // 現フェーズの残り秒 + total: number; // 現フェーズの全長(秒) + label: string; // 例: つぎの休憩まで + targetLabel: string | null; // 例: 12:50 に休憩 + tone: Tone; +} + +// 次のチャイムまでのカウントダウン。リングが減っていく=「チャイムの役割」の視覚化。 +export function CountdownRing({ remaining, total, label, targetLabel, tone }: Props) { + const R = 46; + const C = 2 * Math.PI * R; + const frac = remaining === null || total <= 0 ? 0 : Math.max(0, Math.min(1, remaining / total)); + const offset = C * (1 - frac); + return ( +
+

{label}

+
+ + + + + +
+ {targetLabel && ( +

{targetLabel}

+ )} +
+ ); +} diff --git a/apps/signage/src/components/debug-panel.tsx b/apps/signage/src/components/debug-panel.tsx new file mode 100644 index 0000000..f209520 --- /dev/null +++ b/apps/signage/src/components/debug-panel.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { + type ChimeKind, + classifyCycleMoment, + cycleChimeEventsForDay, + secondsUntilNextBoundary, +} from '@tecnova/shared/activity-cycle'; +import { TERM_LABELS, type TermId } from '@tecnova/shared/venue-schedule'; +import { useState, useSyncExternalStore } from 'react'; +import { getAudioState, playChime, resumeAudio } from '@/lib/chimes'; +import { + type DebugSnapshot, + debugJumpTo, + debugPause, + debugPlay, + debugReset, + debugSetForcedActive, + debugSetSpeed, + debugToggleForcedActive, + getDebugServerSnapshot, + getDebugSnapshot, + getNow, + subscribeDebug, +} from '@/lib/now'; +import { jstHm, mmss } from '@/lib/time'; +import { useNow } from '@/lib/use-now'; +import type { SignageData } from '@/lib/use-signage-data'; + +interface Props { + data: SignageData; + videoIdCount: number; + muted: boolean; + started: boolean; + // 起動タップを踏まずに音声(チャイム)を解放しスケジューラを有効化する。 + onEnableAudio: () => void; +} + +const TERMS: TermId[] = ['morning', 'afternoon', 'evening']; +const SPEEDS = [1, 30, 120]; +const KIND_JA: Record = { + resume: '再開', + break: '休憩', + 'term-end': 'ターム終了', +}; + +const jstHms = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tokyo', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}); + +const useDebug = (): DebugSnapshot => + useSyncExternalStore(subscribeDebug, getDebugSnapshot, getDebugServerSnapshot); + +// ?debug=1 のときだけ page からマウントされる。下部の操作バーで擬似時計・稼働強制・ +// 倍速・チャイムを制御し、待たずに全状態と各遷移+チャイムを検証できるようにする。 +export function DebugPanel({ data, videoIdCount, muted, started, onEnableAudio }: Props) { + const snap = useDebug(); + const now = useNow(snap.playing && snap.speed > 1 ? 250 : 1000); + const [open, setOpen] = useState(true); + const [term, setTerm] = useState(() => classifyCycleMoment(getNow()).term ?? 'morning'); + + const moment = classifyCycleMoment(now); + const audio = getAudioState(); + const audioReady = audio === 'running'; + const secsToBoundary = secondsUntilNextBoundary(now); + const nextChime = cycleChimeEventsForDay(now) + .filter((e) => e.at.getTime() > now.getTime()) + .sort((a, b) => a.at.getTime() - b.at.getTime())[0]; + + // page.tsx と同じ稼働判定。リードアウトを実際の画面表示/スケジューラ発火と一致させる。 + const isActive = (t: TermId | null): boolean => + t !== null && + ((snap.debugEnabled && snap.forcedActiveTerms.includes(t)) || data.termCounts[t] > 0); + const effectivePhase = + moment.term === null ? 'idle(ターム外)' : isActive(moment.term) ? moment.phase : 'idle(未稼働)'; + + // 境界の 5 秒手前へジャンプ(着地後 5 秒で本来の速度のままクロスフェード+チャイムが 1 回)。 + const jumpBefore = (kind: ChimeKind): void => { + const matching = cycleChimeEventsForDay(getNow()).filter( + (e) => e.term === term && e.kind === kind, + ); + // resume はターム頭(idle からの流入)を避け 2 本目以降を優先=休憩→活動の遷移を見せる。 + const target = kind === 'resume' ? (matching[1] ?? matching[0]) : matching[0]; + if (!target) return; + debugSetForcedActive(term, true); // 活動/休憩を出すため稼働 ON + debugJumpTo(target.at.getTime() - 5000); + }; + + // ターム外 idle(昼休み)=朝タームの term-end + 30 分。 + const jumpLunchIdle = (): void => { + const end = cycleChimeEventsForDay(getNow()).find( + (e) => e.term === 'morning' && e.kind === 'term-end', + ); + if (end) debugJumpTo(end.at.getTime() + 30 * 60_000); + }; + + // ターム内・未稼働 idle(まもなく開始)=選択タームの開始直後+稼働 OFF。 + const jumpUnstartedIdle = (): void => { + const start = cycleChimeEventsForDay(getNow()).find( + (e) => e.term === term && e.kind === 'resume', + ); + if (!start) return; + debugSetForcedActive(term, false); + debugJumpTo(start.at.getTime() + 30_000); + }; + + const manualChime = (kind: ChimeKind): void => { + void resumeAudio().then(() => playChime(kind)); + }; + + const btn = + 'rounded-md px-2.5 py-1 text-xs font-bold transition disabled:opacity-40 disabled:cursor-not-allowed'; + const ghost = `${btn} bg-white/10 text-white hover:bg-white/20`; + const on = `${btn} bg-amber-400 text-slate-900`; + + return ( +
+
+ {/* ヘッダ */} +
+ 🛠 プレビュー + + 音声: {audio} + + + +
+ + {!audioReady && ( +
+ チャイムを鳴らすには先に音声を解放してください。 + +
+ )} + + {open && ( + <> + {/* ターム選択 */} +
+ ターム + {TERMS.map((t) => ( + + ))} + 稼働強制 + {TERMS.map((t) => ( + + ))} +
+ + {/* プリセット・ジャンプ(主役)+ idle */} +
+ 5秒前へ + + + + 待機 + + +
+ + {/* トランスポート */} +
+ + 速度 + {SPEEDS.map((s) => ( + + ))} + 手動チャイム + + + +
+ + {/* 状態リードアウト */} +
+ 擬似時刻: {jstHms.format(now)} + phase: {effectivePhase} + + term: {moment.term ?? '—'} / cycle: {moment.cycleIndex ?? '—'} + + 次境界まで: {secsToBoundary === null ? '—' : mmss(secsToBoundary)} + + 次チャイム:{' '} + {nextChime + ? `${KIND_JA[nextChime.kind]} @${jstHm(nextChime.at)}${ + isActive(nextChime.term) ? '' : '(未稼働で鳴りません)' + }` + : '—'} + + + 速度: ×{snap.speed} / {snap.playing ? '再生中' : '停止'} + + + termCounts: 朝{data.termCounts.morning}/昼{data.termCounts.afternoon}/夕 + {data.termCounts.evening} + + 在館: {data.currentlyPresent} + + 動画: {videoIdCount}本 / 音声{muted ? 'ミュート' : 'あり'} /{' '} + {started ? '起動済' : '未起動'} + +
+ + )} +
+
+ ); +} diff --git a/apps/signage/src/components/idle-overlay.tsx b/apps/signage/src/components/idle-overlay.tsx new file mode 100644 index 0000000..25c8457 --- /dev/null +++ b/apps/signage/src/components/idle-overlay.tsx @@ -0,0 +1,46 @@ +'use client'; + +import Image from 'next/image'; +import { jstHm } from '@/lib/time'; + +// 待機中(ターム外 / 開始前)に動画パネルへ重ねる明るいウェルカムスライド。 +export function IdleOverlay({ + now, + soon, + nextStartAt, + present, +}: { + now: Date; + soon: boolean; + nextStartAt: Date | null; + present: number; +}) { + const message = soon + ? 'まもなく はじまるよ!' + : nextStartAt + ? `つぎは ${jstHm(nextStartAt)} から` + : '本日は おしまい。またね!'; + return ( +
+ tec-nova ながさき +

+ {message} +

+

+ {jstHm(now)} +

+ {present > 0 && ( +

+ いま {present} 人が あそびちゅう +

+ )} +
+ ); +} diff --git a/apps/signage/src/components/info-ticker.tsx b/apps/signage/src/components/info-ticker.tsx new file mode 100644 index 0000000..06876ea --- /dev/null +++ b/apps/signage/src/components/info-ticker.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { + IconBrandInstagram, + IconHistory, + IconPlayerPlayFilled, + IconServer, + IconSparkles, + IconUsers, +} from '@tabler/icons-react'; +import { classifyAttendanceLevel, occupancyRatio } from '@tecnova/shared/attendance-level'; +import type { SignagePlaylistItem } from '@tecnova/shared/schemas'; +import { cn } from '@tecnova/ui/lib/utils'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { QRCodeSVG } from 'qrcode.react'; +import type { ReactNode } from 'react'; +import { INSTAGRAM_HANDLE, INSTAGRAM_URL } from '@/config/info-slides'; +import { ATTENDANCE_META } from '@/lib/broadcast'; +import { + tickerLineTransition, + tickerSlideAnimate, + tickerSlideExit, + tickerSlideInitial, + tickerSlideTransition, +} from '@/lib/motion'; +import { usePreviousSummary } from '@/lib/use-previous-summary'; +import { useStoryRotation } from '@/lib/use-story-rotation'; +import { type HealthStatus, useSystemHealth } from '@/lib/use-system-health'; +import { StoryBars } from './story-bars'; +import { StoryProgress } from './story-progress'; + +interface Slide { + id: string; + icon: ReactNode; + chip: string; + label: string; + value: ReactNode; +} + +interface Props { + currentTrack: SignagePlaylistItem | null; + present: number; + totalCheckedIn: number; + debug?: boolean; // ?debug=1 時に手動送りボタンを出す +} + +const HEALTH_META: Record = { + ok: { label: '稼働中', dot: 'bg-emerald-500' }, + checking: { label: '接続を確認中…', dot: 'bg-slate-400' }, + down: { label: '接続できません', dot: 'bg-rose-500' }, +}; + +const prevDateFmt = new Intl.DateTimeFormat('ja-JP', { + timeZone: 'Asia/Tokyo', + month: 'numeric', + day: 'numeric', + weekday: 'short', +}); + +// 配信下部の lower-third。来場・にぎわい・稼働状況・公式 Instagram・前回の情報・動画タイトルを巡回。 +// 巡回の時間源は useStoryRotation(AnimationFrame)に一本化し、進行バーの満ち=送りとする。 +export function InfoTicker({ currentTrack, present, totalCheckedIn, debug }: Props) { + const reduced = useReducedMotion(); + const level = classifyAttendanceLevel(present); + const liveliness = ATTENDANCE_META[level]; + const occ = occupancyRatio(present); + const health = useSystemHealth(); + const previous = usePreviousSummary(); + + const slides: Slide[] = []; + slides.push({ + id: 'attendance', + icon: , + chip: 'bg-emerald-100 text-emerald-700', + label: 'いま 会場にいる人', + value: ( + <> + {present} 人 + + 本日 {totalCheckedIn} 人 + + + ), + }); + slides.push({ + id: 'liveliness', + icon: , + chip: liveliness.chip, + label: 'かいじょうの にぎわい', + value: ( + + {liveliness.label} + + + + + ), + }); + slides.push({ + id: 'health', + icon: , + chip: 'bg-slate-100 text-slate-600', + label: 'テクノバながさきプラットフォーム', + value: ( + + + {HEALTH_META[health].label} + + テクノバを支える基盤システム(実は大学生が1人で開発・運用しているよ) + + + ), + }); + slides.push({ + id: 'instagram', + icon: , + chip: 'bg-pink-100 text-pink-700', + label: '公式インスタグラム', + value: ( + <> + @{INSTAGRAM_HANDLE} + + QR をスマホで読みとってね + + + ), + }); + // 前回開催のデータがあるときだけ(後着なので末尾寄りに置きインデックスずれを防ぐ)。 + if (previous) { + const stay = + previous.averageStayMinutes !== null ? `・平均滞在 ${previous.averageStayMinutes}分` : ''; + slides.push({ + id: 'previous', + icon: , + chip: 'bg-violet-100 text-violet-700', + label: '前回のテクノバ', + value: `${prevDateFmt.format(new Date(`${previous.date}T00:00:00+09:00`))}|${previous.participantCount}人が来場${stay}`, + }); + } + // いま流れている動画タイトルは末尾に追加(先頭挿入だと既存スライドの index がずれる)。 + if (currentTrack?.title) { + slides.push({ + id: 'now-playing', + icon: , + chip: 'bg-sky-100 text-sky-700', + label: 'いま流れているどうが', + value: currentTrack.title, + }); + } + + const count = slides.length; + const { index, goTo, advance, animate } = useStoryRotation(count); + const active = slides[index]; + + return ( +
+ {/* アイコンチップ:key 切替時だけ scale + ごく僅かな傾きを1回(spring)。 */} + + svg]:size-[55%]', + active.chip, + )} + > + {active.icon} + + + + {/* テキスト:クリップ内スライドアップ + ラベル→値スタッガー。 */} +
+ + + + {active.label} + + + {active.value} + + + +
+ + {/* Instagram スライド中だけ、QR をティッカー上へ大きくせり出して見せる + (細い帯に小さく収めると読みづらいため、白カードのコールアウトにする)。 */} + + {active.id === 'instagram' && ( + + + + フォローしてね @{INSTAGRAM_HANDLE} + + + )} + + +
+ {debug && ( + + + + + )} + {animate ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/signage/src/components/live-badge.tsx b/apps/signage/src/components/live-badge.tsx new file mode 100644 index 0000000..8da5aeb --- /dev/null +++ b/apps/signage/src/components/live-badge.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { cn } from '@tecnova/ui/lib/utils'; +import { AIR_STATUS_META, type AirStatus } from '@/lib/broadcast'; + +// ステータスを示すピル(点+ラベル)。脈動(ソナー)アニメは見え方が安定しないため廃止し静的にする。 +export function LiveBadge({ status, className }: { status: AirStatus; className?: string }) { + const meta = AIR_STATUS_META[status]; + return ( + + + {meta.label} + + ); +} diff --git a/apps/signage/src/components/mute-toggle.tsx b/apps/signage/src/components/mute-toggle.tsx new file mode 100644 index 0000000..0d818bf --- /dev/null +++ b/apps/signage/src/components/mute-toggle.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { IconVolume, IconVolumeOff } from '@tabler/icons-react'; + +// 動画パネル右上に置く控えめな音声トグル(運用者向け)。既定=無音。 +export function MuteToggle({ muted, onToggle }: { muted: boolean; onToggle: () => void }) { + return ( + + ); +} diff --git a/apps/signage/src/components/reveal.tsx b/apps/signage/src/components/reveal.tsx new file mode 100644 index 0000000..9d03157 --- /dev/null +++ b/apps/signage/src/components/reveal.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { motion, useReducedMotion } from 'motion/react'; +import type { ReactNode } from 'react'; +import { revealAnimate, revealInitial, revealTransition } from '@/lib/motion'; + +// 子をフェードアップで入場させる薄いラッパ(checkin の Reveal と同じ所作)。 +// index でスタッガーをずらす。prefers-reduced-motion 時は即表示する。 +export function Reveal({ + index = 0, + className, + children, +}: { + index?: number; + className?: string; + children: ReactNode; +}) { + const prefersReduced = useReducedMotion(); + return ( + + {children} + + ); +} diff --git a/apps/signage/src/components/stage-header.tsx b/apps/signage/src/components/stage-header.tsx new file mode 100644 index 0000000..a4f8f24 --- /dev/null +++ b/apps/signage/src/components/stage-header.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Image from 'next/image'; +import type { AirStatus } from '@/lib/broadcast'; +import { jstHm } from '@/lib/time'; +import { LiveBadge } from './live-badge'; + +const jstDate = new Intl.DateTimeFormat('ja-JP', { + timeZone: 'Asia/Tokyo', + month: 'long', + day: 'numeric', + weekday: 'short', +}); + +// 配信フレーム最上段。ワードマーク・ステータス・大きな時計(JST)。 +export function StageHeader({ now, status }: { now: Date; status: AirStatus }) { + return ( +
+ tec-nova + +
+ + {jstDate.format(now)} + + + {jstHm(now)} + +
+
+ ); +} diff --git a/apps/signage/src/components/story-bars.tsx b/apps/signage/src/components/story-bars.tsx new file mode 100644 index 0000000..7a01e82 --- /dev/null +++ b/apps/signage/src/components/story-bars.tsx @@ -0,0 +1,25 @@ +'use client'; + +// 静的な進行バー(reduced-motion / 単一スライド時のフォールバック)。アニメは無く、 +// 現在地までを満タンで示すだけ。アニメ版は StoryProgress(MotionValue 駆動)。 +export function StoryBars({ count, index }: { count: number; index: number }) { + const segments = Array.from({ length: count }, (_, n) => ({ + id: `seg-${n}`, + fill: n <= index ? 1 : 0, + })); + return ( + + {segments.map((s) => ( + + + + ))} + + ); +} diff --git a/apps/signage/src/components/story-progress.tsx b/apps/signage/src/components/story-progress.tsx new file mode 100644 index 0000000..32fbded --- /dev/null +++ b/apps/signage/src/components/story-progress.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react'; +import { useRef } from 'react'; +import { STORY_DURATION_MS } from '@/lib/motion'; + +// アニメーションする進行バー(active のみ MotionValue で満ちる)。本コンポーネントは +// animate=true のときだけマウントされるので、reduced-motion / 単一スライド時は RAF を回さない。 +// progress は MotionValue=毎フレーム更新でも React 再レンダを起こさない(バー幅を直接更新)。 +export function StoryProgress({ + count, + index, + onAdvance, +}: { + count: number; + index: number; + onAdvance: () => void; +}) { + const progress = useMotionValue(0); + const widthPct = useTransform(progress, (p) => `${Math.max(0, Math.min(1, p)) * 100}%`); + const startRef = useRef(null); + // 最新 index と「いまバーが満ちている対象 index」をフレームループから参照する。 + const indexRef = useRef(index); + const barIndexRef = useRef(index); + const onAdvanceRef = useRef(onAdvance); + indexRef.current = index; + onAdvanceRef.current = onAdvance; + + useAnimationFrame((t) => { + // index が変わったら(自動送り・debug の手動送り両方)バーを 0 から満ち直す。 + if (barIndexRef.current !== indexRef.current) { + barIndexRef.current = indexRef.current; + startRef.current = t; + progress.set(0); + } + if (startRef.current === null) startRef.current = t; + const p = (t - startRef.current) / STORY_DURATION_MS; + if (p >= 1) { + startRef.current = t; + progress.set(0); + onAdvanceRef.current(); + return; + } + progress.set(p); + }); + + const segments = Array.from({ length: count }, (_, n) => ({ + id: `seg-${n}`, + state: n < index ? 'done' : n === index ? 'active' : 'todo', + })); + + return ( + + {segments.map((s) => ( + + {s.state === 'active' ? ( + + ) : ( + + )} + + ))} + + ); +} diff --git a/apps/signage/src/components/tap-to-start.tsx b/apps/signage/src/components/tap-to-start.tsx new file mode 100644 index 0000000..5e809ee --- /dev/null +++ b/apps/signage/src/components/tap-to-start.tsx @@ -0,0 +1,30 @@ +'use client'; + +import Image from 'next/image'; + +// キオスク起動ゲート。タップでチャイム解放・全画面・wake lock を有効化する。 +export function TapToStart({ onStart }: { onStart: () => void }) { + return ( + + ); +} diff --git a/apps/signage/src/components/term-timeline.tsx b/apps/signage/src/components/term-timeline.tsx new file mode 100644 index 0000000..d6d27c8 --- /dev/null +++ b/apps/signage/src/components/term-timeline.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { ACTIVITY_MINUTES, BREAK_MINUTES } from '@tecnova/shared/activity-cycle'; +import { TERMS, type TermId } from '@tecnova/shared/venue-schedule'; +import { cn } from '@tecnova/ui/lib/utils'; + +const CYCLE_MINUTES = ACTIVITY_MINUTES + BREAK_MINUTES; // 60 + +// 'HH:mm' → 通算分(venue-schedule のヘルパは非公開のため再宣言)。 +const toMin = (hhmm: string): number => + Number.parseInt(hhmm.slice(0, 2), 10) * 60 + Number.parseInt(hhmm.slice(3, 5), 10); + +// now を JST 通算分に(activity-cycle 内部と同手法。Asia/Tokyo は固定 UTC+9)。 +const jstMinFmt = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tokyo', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); +const nowJstMin = (d: Date): number => { + const [h, m] = jstMinFmt.format(d).split(':'); + return Number.parseInt(h, 10) * 60 + Number.parseInt(m, 10); +}; + +// タームを 3サイクル×(活動50/休憩10)の6セグメント帯にし、現在位置をマーカーで示す。 +// 冗長だった「サイクル N/3 + 3ドット」をこの帯へ統合する。 +export function TermTimeline({ + term, + now, + markerClass, +}: { + term: TermId; + now: Date; + markerClass: string; // 現フェーズ色(bg-emerald-500 など) +}) { + const def = TERMS.find((t) => t.id === term); + if (!def) return null; + const start = toMin(def.start); + const end = toMin(def.end); + const cycles = Math.round((end - start) / CYCLE_MINUTES); + const cur = Math.max(start, Math.min(end, nowJstMin(now))); + const progress = (cur - start) / (end - start); // ターム全体での 0..1 + + // 6セグメント(3サイクル × 活動/休憩)を先に組み立てる(key はサイクル種別で安定)。 + const segments: { id: string; grow: number; lit: boolean; now: boolean; isBreak: boolean }[] = []; + for (let c = 0; c < cycles; c += 1) { + const segStart = start + c * CYCLE_MINUTES; + segments.push({ + id: `a${c}`, + grow: ACTIVITY_MINUTES, + lit: cur >= segStart, + now: cur >= segStart && cur < segStart + ACTIVITY_MINUTES, + isBreak: false, + }); + segments.push({ + id: `b${c}`, + grow: BREAK_MINUTES, + lit: cur >= segStart + ACTIVITY_MINUTES, + now: cur >= segStart + ACTIVITY_MINUTES && cur < segStart + CYCLE_MINUTES, + isBreak: true, + }); + } + + return ( +
+
+ {def.start} + このタームの進み + {def.end} +
+
+
+ {segments.map((s) => ( + + ))} +
+ {/* 現在位置マーカー。CSS transition のみ → reduced-motion 尊重。 */} + +
+
+ ); +} diff --git a/apps/signage/src/components/up-next.tsx b/apps/signage/src/components/up-next.tsx new file mode 100644 index 0000000..de41c79 --- /dev/null +++ b/apps/signage/src/components/up-next.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { type ChimeKind, cycleChimeEventsForDay } from '@tecnova/shared/activity-cycle'; +import { cn } from '@tecnova/ui/lib/utils'; +import { jstHm } from '@/lib/time'; + +const KIND_LABEL: Record = { + resume: '活動再開', + break: '休憩', + 'term-end': 'ターム終了', +}; +const KIND_DOT: Record = { + resume: 'bg-emerald-500', + break: 'bg-amber-500', + 'term-end': 'bg-violet-500', +}; + +// 次2件のチャイムを薄いリストで。リング直下の死に空間を「つぎ なに・いつ」で埋める。 +// 直近1件だけ淡く強調し、2件目は地に沈めて優先度を示す。 +export function UpNext({ now }: { now: Date }) { + const upcoming = cycleChimeEventsForDay(now) + .filter((e) => e.at.getTime() > now.getTime()) + .sort((a, b) => a.at.getTime() - b.at.getTime()) + .slice(0, 2); + if (upcoming.length === 0) return null; + return ( +
+

つぎの あいず

+ {upcoming.map((e, idx) => ( +
+ + + {jstHm(e.at)} + + + {KIND_LABEL[e.kind]} + +
+ ))} +
+ ); +} diff --git a/apps/signage/src/components/video-stage.tsx b/apps/signage/src/components/video-stage.tsx new file mode 100644 index 0000000..641b0c2 --- /dev/null +++ b/apps/signage/src/components/video-stage.tsx @@ -0,0 +1,79 @@ +'use client'; + +import type { CyclePhase } from '@tecnova/shared/activity-cycle'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { BreakOverlay } from './break-overlay'; +import { IdleOverlay } from './idle-overlay'; +import { MuteToggle } from './mute-toggle'; +import { YoutubePlayer } from './youtube-player'; + +interface Props { + phase: CyclePhase; + videoIds: string[]; + muted: boolean; + started: boolean; + onToggleMute: () => void; + onVideoChange: (index: number) => void; + secondsToPhaseEnd: number | null; // 休憩中の残り秒 + now: Date; + soon: boolean; + nextStartAt: Date | null; + present: number; +} + +// 縮小した動画パネル。IFrame は常時マウントし、休憩/待機スライドを上にクロスフェードする。 +export function VideoStage({ + phase, + videoIds, + muted, + started, + onToggleMute, + onVideoChange, + secondsToPhaseEnd, + now, + soon, + nextStartAt, + present, +}: Props) { + const reduced = useReducedMotion(); + return ( +
+ + + + {phase === 'break' && ( + + + + )} + {phase === 'idle' && ( + + + + )} + + + {started && } +
+ ); +} diff --git a/apps/signage/src/components/youtube-player.tsx b/apps/signage/src/components/youtube-player.tsx new file mode 100644 index 0000000..23454ef --- /dev/null +++ b/apps/signage/src/components/youtube-player.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useYoutubePlayer } from '@/lib/use-youtube-player'; + +const PLAYER_ELEMENT_ID = 'signage-youtube-player'; + +interface Props { + videoIds: string[]; + active: boolean; + muted: boolean; + started: boolean; + onVideoChange?: (index: number) => void; +} + +// IFrame は常時マウント(再読込フラッシュ防止)。未ロード時は背後のワードマークを見せ、 +// 動画ロード後に iframe を opacity:1 で前に出す(use-youtube-player が制御)。 +export function YoutubePlayer({ videoIds, active, muted, started, onVideoChange }: Props) { + useYoutubePlayer({ + elementId: PLAYER_ELEMENT_ID, + videoIds, + active, + muted, + started, + onVideoChange, + }); + + return ( +
+
+ tec-nova Nagasaki +
+ {/* YT.Player がこの div を iframe に置換し、JS 側で全画面化+可視制御する。 */} +
+
+ ); +} diff --git a/apps/signage/src/config/info-slides.ts b/apps/signage/src/config/info-slides.ts new file mode 100644 index 0000000..655558a --- /dev/null +++ b/apps/signage/src/config/info-slides.ts @@ -0,0 +1,7 @@ +// 巡回スライド用の静的設定。データ駆動でない値だけをここに置く。 + +// 公式 Instagram のハンドル(先頭の @ は付けない)。 +export const INSTAGRAM_HANDLE = 'tecnovanagasaki'; + +// 上記から組み立てる公式 Instagram の URL(QR・リンク用)。 +export const INSTAGRAM_URL = `https://www.instagram.com/${INSTAGRAM_HANDLE}/`; diff --git a/apps/signage/src/config/playlist.ts b/apps/signage/src/config/playlist.ts new file mode 100644 index 0000000..0a0c9f7 --- /dev/null +++ b/apps/signage/src/config/playlist.ts @@ -0,0 +1,4 @@ +// API(/api/signage/playlist)が主ソース。取得失敗・空配列・ローカル開発時のみ +// この配列を自前キューに流す(spec §5.4)。動画 URL ではなく YouTube の videoId を列挙する。 +// 例: 'dQw4w9WgXcQ'。空のままなら(API も空なら)動画レイヤ背後のワードマークが見える。 +export const FALLBACK_VIDEO_IDS: string[] = []; diff --git a/apps/signage/src/lib/auth-client.ts b/apps/signage/src/lib/auth-client.ts new file mode 100644 index 0000000..558a94e --- /dev/null +++ b/apps/signage/src/lib/auth-client.ts @@ -0,0 +1,11 @@ +import { createAuthClient } from 'better-auth/react'; + +// Worker(API)の URL。本番では NEXT_PUBLIC_API_URL を Vercel 側で設定する。 +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'; + +// クッキーは Worker 側オリジンに発行される。サイネージは別オリジンから fetch するため +// credentials:'include' でクッキーを同送させる(API の TRUSTED_ORIGINS に 3002 を登録済み)。 +export const authClient = createAuthClient({ + baseURL: API_URL, + fetchOptions: { credentials: 'include' }, +}); diff --git a/apps/signage/src/lib/broadcast.ts b/apps/signage/src/lib/broadcast.ts new file mode 100644 index 0000000..aac16f9 --- /dev/null +++ b/apps/signage/src/lib/broadcast.ts @@ -0,0 +1,40 @@ +import type { CyclePhase } from '@tecnova/shared/activity-cycle'; +import type { AttendanceLevel } from '@tecnova/shared/attendance-level'; + +// 配信(ブロードキャスト)画面のステータス。チャイムの役割を持つ右レーンとヘッダのバッジで使う。 +export type AirStatus = 'live' | 'break' | 'soon' | 'standby' | 'ended'; + +export const airStatus = ({ + phase, + soon, + hasNext, +}: { + phase: CyclePhase; + soon: boolean; // ターム内・未稼働(まもなく開始) + hasNext: boolean; // この後にまだタームがある +}): AirStatus => { + if (phase === 'activity') return 'live'; + if (phase === 'break') return 'break'; + if (soon) return 'soon'; + return hasNext ? 'standby' : 'ended'; +}; + +// ステータスごとの見た目。dot=点の色 / chip=ピル。 +export const AIR_STATUS_META: Record = { + live: { label: '活動中', dot: 'bg-emerald-500', chip: 'bg-emerald-100 text-emerald-700' }, + break: { label: '休憩中', dot: 'bg-amber-500', chip: 'bg-amber-100 text-amber-800' }, + soon: { label: 'まもなく開始', dot: 'bg-sky-500', chip: 'bg-sky-100 text-sky-700' }, + standby: { label: '準備中', dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }, + ended: { label: '本日終了', dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }, +}; + +// にぎわいレベルごとの見た目。実際のメーター長は occupancyRatio(present)(上限25人)で出す。 +export const ATTENDANCE_META: Record< + AttendanceLevel, + { label: string; chip: string; bar: string } +> = { + quiet: { label: 'ゆったり', chip: 'bg-slate-100 text-slate-600', bar: 'bg-slate-400' }, + steady: { label: 'ほどよい', chip: 'bg-sky-100 text-sky-700', bar: 'bg-sky-500' }, + lively: { label: 'にぎやか', chip: 'bg-emerald-100 text-emerald-700', bar: 'bg-emerald-500' }, + crowded: { label: '大にぎわい', chip: 'bg-amber-100 text-amber-800', bar: 'bg-amber-500' }, +}; diff --git a/apps/signage/src/lib/chimes.ts b/apps/signage/src/lib/chimes.ts new file mode 100644 index 0000000..c141785 --- /dev/null +++ b/apps/signage/src/lib/chimes.ts @@ -0,0 +1,60 @@ +import type { ChimeKind } from '@tecnova/shared/activity-cycle'; + +// 単一の AudioContext を使い回す(ブラウザは同時 context 数を制限するため)。 +let ctx: AudioContext | null = null; + +const getCtx = (): AudioContext => { + if (!ctx) ctx = new AudioContext(); + return ctx; +}; + +// 起動タップ内で呼ぶ。自動再生制約を解放する。 +export const resumeAudio = async (): Promise => { + const c = getCtx(); + if (c.state !== 'running') await c.resume(); +}; + +// suspended に戻っていれば再開(OS スリープ後・タブ復帰時)。 +export const ensureAudioRunning = async (): Promise => { + if (ctx && ctx.state !== 'running') await ctx.resume(); +}; + +// デバッグパネルの状態表示用。未生成なら 'none'。 +export const getAudioState = (): AudioContextState | 'none' => ctx?.state ?? 'none'; + +const tone = ( + c: AudioContext, + freq: number, + startAt: number, + dur: number, + type: OscillatorType, +): void => { + const osc = c.createOscillator(); + const gain = c.createGain(); + osc.type = type; + osc.frequency.value = freq; + // 指数エンベロープでベル風の余韻(0 には到達できないので 0.0001 へ)。 + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(0.5, startAt + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); + osc.connect(gain).connect(c.destination); + osc.start(startAt); + osc.stop(startAt + dur + 0.05); +}; + +// 種別ごとに音色・音程を変える。 +const PATTERNS: Record = + { + resume: { freqs: [784, 988], type: 'sine', dur: 0.7 }, // 上行=再開 + break: { freqs: [988, 784], type: 'sine', dur: 0.8 }, // 下行=休憩(キンコン) + 'term-end': { freqs: [880, 587], type: 'triangle', dur: 1.2 }, // 長め=ターム終了 + }; + +export const playChime = (kind: ChimeKind): void => { + const c = getCtx(); + if (c.state !== 'running') return; // 解放前は鳴らさない + const { freqs, type, dur } = PATTERNS[kind]; + const t = c.currentTime + 0.02; + tone(c, freqs[0], t, dur, type); + tone(c, freqs[1], t + 0.45, dur, type); +}; diff --git a/apps/signage/src/lib/motion.ts b/apps/signage/src/lib/motion.ts new file mode 100644 index 0000000..9de0759 --- /dev/null +++ b/apps/signage/src/lib/motion.ts @@ -0,0 +1,33 @@ +import type { Transition } from 'motion/react'; + +// checkin と同じ入場イージング・タイミングに揃え、プラットフォーム全体で一貫した所作にする。 +const EASE_OUT = 'easeOut' as const; + +// カード/セクションのフェードアップ入場。 +export const revealInitial = { opacity: 0, y: 12 } as const; +export const revealAnimate = { opacity: 1, y: 0 } as const; +export const REVEAL_STAGGER_STEP = 0.06; +export const revealTransition = (index = 0): Transition => ({ + duration: 0.4, + ease: EASE_OUT, + delay: index * REVEAL_STAGGER_STEP, +}); + +// ============================================================================ +// 下部インフォメーション(lower-third)の所作 +// ============================================================================ +// ストーリーズ進行バー1本の寿命(ms)。スライド巡回間隔と一致させる(時間源を一本化)。 +export const STORY_DURATION_MS = 8000; + +// クリップ内のスライドアップ。親の overflow-hidden 前提で 100% 単位に動かす。 +export const tickerSlideInitial = { y: '100%', opacity: 0 } as const; +export const tickerSlideAnimate = { y: '0%', opacity: 1 } as const; +export const tickerSlideExit = { y: '-100%', opacity: 0 } as const; +export const tickerSlideTransition: Transition = { duration: 0.5, ease: EASE_OUT }; + +// ラベル→値のスタッガー(REVEAL_STAGGER_STEP と同刻み)。 +export const tickerLineTransition = (index = 0): Transition => ({ + duration: 0.4, + ease: EASE_OUT, + delay: 0.12 + index * REVEAL_STAGGER_STEP, +}); diff --git a/apps/signage/src/lib/now.ts b/apps/signage/src/lib/now.ts new file mode 100644 index 0000000..8a279a1 --- /dev/null +++ b/apps/signage/src/lib/now.ts @@ -0,0 +1,214 @@ +import type { TermId } from '@tecnova/shared/venue-schedule'; + +// ============================================================================ +// 時刻ソース +// ============================================================================ +// 端末のローカル時計を返す。?now=ISO クエリ(実速進行)に加え、?debug=1 のときは +// 下のデバッグストアによる擬似時計(倍速・一時停止・ジャンプ)で上書きできる。 +// getNow はレンダーに依存しない純粋な読み取りなので、useNow とチャイムスケジューラの +// 双方が同じ時刻を引ける。 + +interface Anchor { + base: number; + mountedAt: number; +} + +let anchor: Anchor | null | undefined; + +const readAnchor = (): Anchor | null => { + if (typeof window === 'undefined') return null; + const raw = new URLSearchParams(window.location.search).get('now'); + if (!raw) return null; + const base = new Date(raw).getTime(); + if (Number.isNaN(base)) return null; + return { base, mountedAt: Date.now() }; +}; + +// ============================================================================ +// デバッグ(プレビュー)ストア +// ============================================================================ +// React 外の module-singleton。?debug=1 のときだけ有効化し、擬似時計+稼働強制を +// 提供する。本番(?debug 無し)では debugEnabled=false のまま全分岐が短絡し、挙動は +// 従来と完全に同一。UI 側は useSyncExternalStore で購読する。 + +interface Override { + base: number; // 擬似now の基準(UTC ms) + mountedAt: number; // base を据えた実 Date.now() + speed: number; // 倍率(1=等速) + playing: boolean; // 進行中か +} + +export interface DebugSnapshot { + debugEnabled: boolean; + hasOverride: boolean; + speed: number; + playing: boolean; + forcedActiveTerms: readonly TermId[]; + jumpEpoch: number; +} + +interface DebugInternal { + debugEnabled: boolean; + override: Override | null; + forcedActiveTerms: Set; + jumpEpoch: number; +} + +const internal: DebugInternal = { + debugEnabled: false, + override: null, + forcedActiveTerms: new Set(), + jumpEpoch: 0, +}; + +const listeners = new Set<() => void>(); + +// useSyncExternalStore は getSnapshot の参照安定を要求するため、変化時のみ再生成する。 +const buildSnapshot = (): DebugSnapshot => ({ + debugEnabled: internal.debugEnabled, + hasOverride: internal.override !== null, + speed: internal.override?.speed ?? 1, + playing: internal.override?.playing ?? true, + forcedActiveTerms: [...internal.forcedActiveTerms], + jumpEpoch: internal.jumpEpoch, +}); + +let snapshot: DebugSnapshot = buildSnapshot(); + +// SSR と CSR 初回(hydration)で必ず一致する固定スナップショット。 +const SERVER_SNAPSHOT: DebugSnapshot = { + debugEnabled: false, + hasOverride: false, + speed: 1, + playing: true, + forcedActiveTerms: [], + jumpEpoch: 0, +}; + +const notify = (): void => { + snapshot = buildSnapshot(); + for (const l of listeners) l(); +}; + +export const subscribeDebug = (cb: () => void): (() => void) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +}; + +export const getDebugSnapshot = (): DebugSnapshot => snapshot; +export const getDebugServerSnapshot = (): DebugSnapshot => SERVER_SNAPSHOT; + +// 現在の擬似now(ms)。override 進行を含む。 +const debugNowMs = (): number => { + const o = internal.override; + if (!o) return Date.now(); + return o.playing ? o.base + (Date.now() - o.mountedAt) * o.speed : o.base; +}; + +// speed / playing 変更前に現在の擬似now を base に確定する(連続性を保ち時刻を飛ばさない)。 +const reanchor = (): void => { + if (!internal.override) return; + internal.override = { + ...internal.override, + base: debugNowMs(), + mountedAt: Date.now(), + }; +}; + +// ?debug=1 判定(クライアントのみ)。 +export const isDebugQueryEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + const v = new URLSearchParams(window.location.search).get('debug'); + return v !== null && v !== '0' && v !== 'false'; +}; + +export const enableDebug = (): void => { + if (internal.debugEnabled) return; + internal.debugEnabled = true; + // ?now= が付いていれば、その時刻を擬似時計の初期 base にする。 + if (anchor === undefined) anchor = readAnchor(); + if (anchor !== null) { + internal.override = { + base: anchor.base + (Date.now() - anchor.mountedAt), + mountedAt: Date.now(), + speed: 1, + playing: true, + }; + } + notify(); +}; + +// 擬似now を ms へジャンプ(不連続)。speed=1・playing=true に揃え、世代を進める +// (スケジューラはこの世代変化を見て過去境界の一斉発火を抑止する)。 +export const debugJumpTo = (ms: number): void => { + internal.override = { base: ms, mountedAt: Date.now(), speed: 1, playing: true }; + internal.jumpEpoch += 1; + notify(); +}; + +export const debugSetSpeed = (speed: number): void => { + if (!internal.override) { + internal.override = { base: Date.now(), mountedAt: Date.now(), speed, playing: true }; + } else { + reanchor(); + internal.override = { ...internal.override, speed }; + } + notify(); +}; + +export const debugPlay = (): void => { + if (!internal.override) return; + reanchor(); + internal.override = { ...internal.override, playing: true }; + notify(); +}; + +export const debugPause = (): void => { + if (!internal.override) { + internal.override = { base: Date.now(), mountedAt: Date.now(), speed: 1, playing: false }; + } else { + reanchor(); + internal.override = { ...internal.override, playing: false }; + } + notify(); +}; + +export const debugSetForcedActive = (term: TermId, on: boolean): void => { + const next = new Set(internal.forcedActiveTerms); + if (on) next.add(term); + else next.delete(term); + internal.forcedActiveTerms = next; + notify(); +}; + +export const debugToggleForcedActive = (term: TermId): void => { + debugSetForcedActive(term, !internal.forcedActiveTerms.has(term)); +}; + +export const debugReset = (): void => { + internal.override = null; + internal.forcedActiveTerms = new Set(); + internal.jumpEpoch += 1; + // 「実時刻に戻す」なので ?now アンカーも解除し、getNow を実時刻(分岐3)へ完全に戻す。 + anchor = null; + notify(); +}; + +// ============================================================================ +// getNow(優先順位: デバッグ擬似時計 → ?now アンカー → 実時刻) +// ============================================================================ +export const getNow = (): Date => { + // (1) デバッグ擬似時計(?debug=1 + override) + if (internal.debugEnabled && internal.override) { + return new Date(debugNowMs()); + } + // (2) ?now= アンカー(実速進行・従来動作) + if (anchor === undefined) anchor = readAnchor(); + if (anchor !== null) { + return new Date(anchor.base + (Date.now() - anchor.mountedAt)); + } + // (3) 実時刻 + return new Date(); +}; diff --git a/apps/signage/src/lib/time.ts b/apps/signage/src/lib/time.ts new file mode 100644 index 0000000..e8df590 --- /dev/null +++ b/apps/signage/src/lib/time.ts @@ -0,0 +1,17 @@ +const jstHmFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tokyo', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +// JST の 'HH:mm'。 +export const jstHm = (date: Date): string => jstHmFormatter.format(date); + +// 秒数を 'M:SS' に(休憩カウントダウン用)。負値は 0 扱い。 +export const mmss = (totalSeconds: number): string => { + const s = Math.max(0, Math.floor(totalSeconds)); + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m}:${String(sec).padStart(2, '0')}`; +}; diff --git a/apps/signage/src/lib/use-chime-scheduler.ts b/apps/signage/src/lib/use-chime-scheduler.ts new file mode 100644 index 0000000..cd41721 --- /dev/null +++ b/apps/signage/src/lib/use-chime-scheduler.ts @@ -0,0 +1,87 @@ +'use client'; + +import { type ChimeEvent, cycleChimeEventsForDay } from '@tecnova/shared/activity-cycle'; +import { useEffect, useRef } from 'react'; + +interface Args { + enabled: boolean; // 音声解放済みか(起動タップ後) + isTermActive: (term: ChimeEvent['term']) => boolean; // 稼働判定 + onChime: (event: ChimeEvent) => void; // 発火時の副作用(playChime 等) + getNow: () => Date; + // デバッグの不連続ジャンプ世代。本番では常に 0。変化を検知したら catch-up を抑止する。 + jumpEpoch?: number; +} + +// 壁時計の :00/:50 等の境界でちょうど発火させる。setInterval は使わず、毎 tick +// Date から次境界までの遅延を再計算する(ドリフトしない)。key で二重発火を防ぐ。 +export const useChimeScheduler = ({ + enabled, + isTermActive, + onChime, + getNow, + jumpEpoch = 0, +}: Args): void => { + const isActiveRef = useRef(isTermActive); + const onChimeRef = useRef(onChime); + const getNowRef = useRef(getNow); + const jumpEpochRef = useRef(jumpEpoch); + isActiveRef.current = isTermActive; + onChimeRef.current = onChime; + getNowRef.current = getNow; + jumpEpochRef.current = jumpEpoch; + + useEffect(() => { + if (!enabled) return; + const fired = new Set(); + let last = getNowRef.current().getTime(); + let seenEpoch = jumpEpochRef.current; + let timer = 0; + + const tick = (): void => { + const now = getNowRef.current().getTime(); + // 不連続ジャンプ/リセット直後は窓 (last, now] を畳んで過去境界の一斉発火を抑止する + // (fired もクリアし、後方ジャンプで同じ境界を再現できるようにする)。 + if (seenEpoch !== jumpEpochRef.current) { + seenEpoch = jumpEpochRef.current; + fired.clear(); + last = now; + } + const events = cycleChimeEventsForDay(new Date(now)); + // 同一インスタントに複数イベントが重なる場合(例: 隣接タームの term-end@16:00 と + // resume@16:00 が両方稼働中のとき)、チャイム音が重ならないよう稼働中の1件だけ鳴らす。 + // 通常運用ではタームは日替わりで排他なので衝突は起きず、従来挙動と完全同値。 + const firedAt = new Set(); + for (const e of events) { + const at = e.at.getTime(); + if (at > last && at <= now && !fired.has(e.key)) { + fired.add(e.key); // key 単位の二重発火防止(tick をまたいで)は全件に効かせる + if (isActiveRef.current(e.term) && !firedAt.has(at)) { + firedAt.add(at); + onChimeRef.current(e); + } + } + } + last = now; + const nextAt = events + .map((e) => e.at.getTime()) + .filter((t) => t > now) + .sort((a, b) => a - b)[0]; + const delay = nextAt === undefined ? 1000 : Math.min(1000, Math.max(50, nextAt - now)); + timer = window.setTimeout(tick, delay); + }; + + const onVisible = (): void => { + if (document.visibilityState === 'visible') { + window.clearTimeout(timer); + tick(); + } + }; + + timer = window.setTimeout(tick, 0); + document.addEventListener('visibilitychange', onVisible); + return () => { + window.clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [enabled]); +}; diff --git a/apps/signage/src/lib/use-mute.ts b/apps/signage/src/lib/use-mute.ts new file mode 100644 index 0000000..842aa5b --- /dev/null +++ b/apps/signage/src/lib/use-mute.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +// 無音/音ありトグル。既定=無音(true)。localStorage に永続(spec §5.5)。 +const STORAGE_KEY = 'signage:muted'; + +export const useMute = (): { muted: boolean; toggle: () => void } => { + // SSR では localStorage が無いので既定=無音で初期化し、mount 後に読み出す。 + const [muted, setMuted] = useState(true); + + useEffect(() => { + if (window.localStorage.getItem(STORAGE_KEY) === 'false') setMuted(false); + }, []); + + const toggle = useCallback(() => { + setMuted((prev) => { + const next = !prev; + window.localStorage.setItem(STORAGE_KEY, String(next)); + return next; + }); + }, []); + + return { muted, toggle }; +}; diff --git a/apps/signage/src/lib/use-now.ts b/apps/signage/src/lib/use-now.ts new file mode 100644 index 0000000..d2b214d --- /dev/null +++ b/apps/signage/src/lib/use-now.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getNow, subscribeDebug } from './now'; + +// 表示更新用に一定間隔で現在時刻を返す(チャイム発火は use-chime-scheduler が別途精密に行う)。 +// デバッグ操作(ジャンプ/一時停止/速度変更)は subscribeDebug で即時反映する。 +export const useNow = (intervalMs = 1000): Date => { + const [now, setNow] = useState(() => getNow()); + useEffect(() => { + const tick = (): void => setNow(getNow()); + const id = window.setInterval(tick, intervalMs); + const unsub = subscribeDebug(tick); + return () => { + window.clearInterval(id); + unsub(); + }; + }, [intervalMs]); + return now; +}; diff --git a/apps/signage/src/lib/use-playlist.ts b/apps/signage/src/lib/use-playlist.ts new file mode 100644 index 0000000..a9367ff --- /dev/null +++ b/apps/signage/src/lib/use-playlist.ts @@ -0,0 +1,38 @@ +'use client'; + +import type { SignagePlaylistItem, SignagePlaylistResponse } from '@tecnova/shared/schemas'; +import { apiJson } from '@tecnova/ui/lib/api-client'; +import { useEffect, useState } from 'react'; +import { FALLBACK_VIDEO_IDS } from '@/config/playlist'; + +// 起動時 + 数分間隔で /api/signage/playlist を取得し再生キュー(videoId + タイトル)を保持する。 +// 取得失敗 / 空配列のときは FALLBACK_VIDEO_IDS を採用(spec §5.1 / §5.4)。 +// タイトルは API(YouTube Data API)由来で、サイネージのインフォメーション表示に使う。 +const POLL_MS = 5 * 60_000; + +const FALLBACK_TRACKS: SignagePlaylistItem[] = FALLBACK_VIDEO_IDS.map((videoId) => ({ videoId })); + +export const usePlaylist = (): SignagePlaylistItem[] => { + const [tracks, setTracks] = useState(FALLBACK_TRACKS); + + useEffect(() => { + let active = true; + const load = async (): Promise => { + try { + const res = await apiJson('/api/signage/playlist'); + if (!active) return; + setTracks(res.items.length > 0 ? res.items : FALLBACK_TRACKS); + } catch { + // 取得失敗時は直近の状態を保持(degrade)。初回失敗なら FALLBACK のまま。 + } + }; + void load(); + const id = window.setInterval(() => void load(), POLL_MS); + return () => { + active = false; + window.clearInterval(id); + }; + }, []); + + return tracks; +}; diff --git a/apps/signage/src/lib/use-previous-summary.ts b/apps/signage/src/lib/use-previous-summary.ts new file mode 100644 index 0000000..574c408 --- /dev/null +++ b/apps/signage/src/lib/use-previous-summary.ts @@ -0,0 +1,35 @@ +'use client'; + +import type { SignagePreviousSummaryResponse } from '@tecnova/shared/schemas'; +import { apiJson } from '@tecnova/ui/lib/api-client'; +import { useEffect, useState } from 'react'; + +type Previous = SignagePreviousSummaryResponse['previous']; + +// 前回開催の来場・滞在データ(集計のみ)。日付をまたぐ可能性に備え1時間ごとに再取得。 +// 失敗時は直近値を保持(degrade)。前回開催が無ければ null。 +const POLL_MS = 60 * 60_000; + +export const usePreviousSummary = (): Previous => { + const [previous, setPrevious] = useState(null); + + useEffect(() => { + let active = true; + const load = async (): Promise => { + try { + const res = await apiJson('/api/signage/previous-summary'); + if (active) setPrevious(res.previous); + } catch { + // 直近値を保持。 + } + }; + void load(); + const id = window.setInterval(() => void load(), POLL_MS); + return () => { + active = false; + window.clearInterval(id); + }; + }, []); + + return previous; +}; diff --git a/apps/signage/src/lib/use-signage-data.ts b/apps/signage/src/lib/use-signage-data.ts new file mode 100644 index 0000000..c6bdb1f --- /dev/null +++ b/apps/signage/src/lib/use-signage-data.ts @@ -0,0 +1,55 @@ +'use client'; + +import type { TodaySessionsResponse } from '@tecnova/shared/schemas'; +import type { TermId } from '@tecnova/shared/venue-schedule'; +import { apiJson } from '@tecnova/ui/lib/api-client'; +import { useEffect, useState } from 'react'; + +export interface SignageData { + currentlyPresent: number; + totalCheckedIn: number; + termCounts: Record; +} + +const EMPTY: SignageData = { + currentlyPresent: 0, + totalCheckedIn: 0, + termCounts: { morning: 0, afternoon: 0, evening: 0 }, +}; + +const POLL_MS = 20_000; + +// 認証付き /api/sessions/today を ~20秒間隔で取得。ターム別 checkedIn は +// sessions[].term の件数(累計=ターム終了まで sticky)。失敗時は直近値を保持。 +export const useSignageData = (): SignageData => { + const [data, setData] = useState(EMPTY); + + useEffect(() => { + let active = true; + const load = async (): Promise => { + try { + const res = await apiJson('/api/sessions/today'); + if (!active) return; + const termCounts: Record = { morning: 0, afternoon: 0, evening: 0 }; + for (const s of res.sessions) { + if (s.term) termCounts[s.term] += 1; + } + setData({ + currentlyPresent: res.summary.currentlyPresent, + totalCheckedIn: res.summary.totalCheckedIn, + termCounts, + }); + } catch { + // ネットワーク不達時は直近値を維持(degrade)。 + } + }; + void load(); + const id = window.setInterval(() => void load(), POLL_MS); + return () => { + active = false; + window.clearInterval(id); + }; + }, []); + + return data; +}; diff --git a/apps/signage/src/lib/use-story-rotation.ts b/apps/signage/src/lib/use-story-rotation.ts new file mode 100644 index 0000000..6916ab9 --- /dev/null +++ b/apps/signage/src/lib/use-story-rotation.ts @@ -0,0 +1,37 @@ +'use client'; + +import { useReducedMotion } from 'motion/react'; +import { useCallback, useEffect, useState } from 'react'; +import { STORY_DURATION_MS } from '@/lib/motion'; + +// 下部インフォの巡回管理。index(現在スライド)と送り操作だけを React state で持ち、 +// 進行バーの満ち(毎フレーム)は StoryProgress 側の MotionValue が担う(本体を毎フレーム +// 再レンダしないため)。巡回は実時間固定で、?debug の擬似時計・倍速には追従しない +// (チャイムの活動時計とは独立した情報表示のため)。 +export function useStoryRotation(count: number) { + const reduced = useReducedMotion(); + const [index, setIndex] = useState(0); + + const advance = useCallback(() => { + setIndex((p) => (count > 0 ? (p + 1) % count : 0)); + }, [count]); + + const goTo = useCallback( + (next: number) => { + setIndex(count > 0 ? ((next % count) + count) % count : 0); + }, + [count], + ); + + // reduced-motion 時は進行バーを動かさず、一定間隔で送りだけ続ける(情報は一巡する)。 + useEffect(() => { + if (!reduced || count <= 1) return; + const id = window.setInterval(advance, STORY_DURATION_MS); + return () => window.clearInterval(id); + }, [reduced, count, advance]); + + // animate=true のときだけ RAF 駆動の StoryProgress をマウントする(不要時は RAF を回さない)。 + const animate = !reduced && count > 1; + + return { index: count > 0 ? index % count : 0, goTo, advance, animate }; +} diff --git a/apps/signage/src/lib/use-system-health.ts b/apps/signage/src/lib/use-system-health.ts new file mode 100644 index 0000000..6a80485 --- /dev/null +++ b/apps/signage/src/lib/use-system-health.ts @@ -0,0 +1,34 @@ +'use client'; + +import type { SignageHealthResponse } from '@tecnova/shared/schemas'; +import { apiJson } from '@tecnova/ui/lib/api-client'; +import { useEffect, useState } from 'react'; + +export type HealthStatus = 'checking' | 'ok' | 'down'; + +// 基盤システムの稼働確認。/api/signage/health を定期 ping し、成功=ok / 失敗=down。 +const POLL_MS = 30_000; + +export const useSystemHealth = (): HealthStatus => { + const [status, setStatus] = useState('checking'); + + useEffect(() => { + let active = true; + const ping = async (): Promise => { + try { + const res = await apiJson('/api/signage/health'); + if (active) setStatus(res.status === 'ok' ? 'ok' : 'down'); + } catch { + if (active) setStatus('down'); + } + }; + void ping(); + const id = window.setInterval(() => void ping(), POLL_MS); + return () => { + active = false; + window.clearInterval(id); + }; + }, []); + + return status; +}; diff --git a/apps/signage/src/lib/use-wake-lock.ts b/apps/signage/src/lib/use-wake-lock.ts new file mode 100644 index 0000000..b71ba9c --- /dev/null +++ b/apps/signage/src/lib/use-wake-lock.ts @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; + +// 画面スリープ防止。document が hidden になると OS が自動解放するため +// visibilitychange で再取得する。HTTPS(secure context)必須。 +export const useWakeLock = (enabled: boolean): void => { + useEffect(() => { + if (!enabled || !('wakeLock' in navigator)) return; + let lock: WakeLockSentinel | null = null; + + const request = async (): Promise => { + try { + lock = await navigator.wakeLock.request('screen'); + } catch { + // low battery / hidden などで拒否されうる(ベストエフォート)。 + } + }; + const onVisible = (): void => { + if (document.visibilityState === 'visible') void request(); + }; + + void request(); + document.addEventListener('visibilitychange', onVisible); + return () => { + document.removeEventListener('visibilitychange', onVisible); + void lock?.release().catch(() => {}); + }; + }, [enabled]); +}; diff --git a/apps/signage/src/lib/use-youtube-player.ts b/apps/signage/src/lib/use-youtube-player.ts new file mode 100644 index 0000000..e757676 --- /dev/null +++ b/apps/signage/src/lib/use-youtube-player.ts @@ -0,0 +1,190 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +// IFrame Player API は @types/youtube がグローバル名前空間 YT を提供する。 +// window.YT / onYouTubeIframeAPIReady は型に無いので最小限で宣言する。 +declare global { + interface Window { + YT?: typeof YT; + onYouTubeIframeAPIReady?: () => void; + } +} + +// IFrame Player API を一度だけ読み込むシングルトン。複数回呼んでも