diff --git a/AGENTS.md b/AGENTS.md index 059080e..16aa3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,10 @@ npm run preview requires a break. - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body indentation. +- For arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid. - Never write Ruby, TypeScript, or TSX lines longer than 99 characters. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 622272b..9553712 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -83,6 +83,10 @@ service, representation, and spec. by 4 spaces. - Put one logical pair per line when the expression would otherwise become dense. +- For Ruby arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - For Ruby blocks, use 2-space indentation for the block body. - Keep comments short and useful; avoid narrating obvious code. - Do not add production dependencies without approval. diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb index 51efb84..124c913 100644 --- a/backend/app/services/theatre_post_selector.rb +++ b/backend/app/services/theatre_post_selector.rb @@ -1,5 +1,10 @@ class TheatrePostSelector Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true) + ELIGIBLE_POST_URL_CONDITION = + ["url LIKE '%nicovideo.jp%'", + "url LIKE '%youtube.com/watch%'", + "url LIKE '%youtu.be/%'"] + .join(' OR ') def initialize theatre: @theatre = theatre @@ -52,7 +57,7 @@ class TheatrePostSelector end def eligible_posts - posts = Post.where("url LIKE '%nicovideo.jp%'") + posts = Post.where(ELIGIBLE_POST_URL_CONDITION) posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id posts end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 00ab143..4a5a198 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -28,6 +28,13 @@ RSpec.describe 'Theatres API', type: :request do ) end + let!(:youtube_post) do + Post.create!( + title: 'youtube post', + url: 'https://www.youtube.com/watch?v=yt123' + ) + end + let!(:other_post) do Post.create!( title: 'other post', @@ -286,7 +293,7 @@ RSpec.describe 'Theatres API', type: :request do .to change { theatre.reload.current_post_id } expect(response).to have_http_status(:no_content) - expect([niconico_post.id, second_niconico_post.id]) + expect([niconico_post.id, second_niconico_post.id, youtube_post.id]) .to include(theatre.reload.current_post_id) expect(theatre.reload.current_post_started_at) .to be_within(1.second).of(Time.current) @@ -294,10 +301,27 @@ RSpec.describe 'Theatres API', type: :request do end end + context 'when only a YouTube post is eligible' do + before do + niconico_post.destroy! + second_niconico_post.destroy! + theatre.update!(host_user: member) + sign_in_as(member) + end + + it 'sets current_post to the YouTube post' do + do_request + + expect(response).to have_http_status(:no_content) + expect(theatre.reload.current_post_id).to eq(youtube_post.id) + end + end + context 'when current user is host and no eligible post exists' do before do niconico_post.destroy! second_niconico_post.destroy! + youtube_post.destroy! theatre.update!( host_user: member, current_post: other_post, diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9150c92..124c9b7 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -47,6 +47,10 @@ If either command cannot be run or fails, report the exact command and failure. - Never write a TypeScript or TSX line longer than 99 characters. - Aim to keep TypeScript and TSX lines within 79 characters where practical. - Use 4-space logical indentation in TypeScript and TSX. +- For arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab to reduce bytes. - Treat one leading tab as exactly equivalent to 8 leading spaces. diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index ed65823..4078ef3 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from ' type NiconicoPlayerMessage = | { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' } - | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } } - | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata } - | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown } - | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string } + | { eventName: 'loadComplete' + playerId?: string + data: { videoInfo: NiconicoVideoInfo } } + | { eventName: 'playerMetadataChange' + playerId?: string + data: NiconicoMetadata } + | { eventName: 'playerStatusChange' | 'statusChange' + playerId?: string + data?: unknown } + | { eventName: 'error' + playerId?: string + data?: unknown + code?: string + message?: string } type NiconicoCommand = | { eventName: 'play'; sourceConnectorType: 1; playerId: string } @@ -30,6 +40,7 @@ type NiconicoCommand = data: { commentVisibility: boolean } } const EMBED_ORIGIN = 'https://embed.nicovideo.jp' +const LOAD_COMPLETE_TIMEOUT_MS = 8_000 type Props = { id: string @@ -42,10 +53,13 @@ type Props = { export default forwardRef ((props: Props, ref: ForwardedRef) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props - const iframeRef = useRef (null) - const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) + const iframeRef = useRef (null) + const loadCompleteTimerRef = useRef | null> (null) + const playerId = useMemo ( + () => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, + [id]) const [screenWidth, setScreenWidth] = useState () const [screenHeight, setScreenHeight] = useState () @@ -77,8 +91,26 @@ export default forwardRef ((props: Props, ref: ForwardedRef { + if (!(loadCompleteTimerRef.current)) + return + + clearTimeout (loadCompleteTimerRef.current) + loadCompleteTimerRef.current = null + }, []) + + const startLoadCompleteTimer = useCallback (() => { + clearLoadCompleteTimer () + loadCompleteTimerRef.current = setTimeout (() => { + onError?.({ + eventName: 'loadCompleteTimeout', + reason: 'niconico video length was not reported by embed', + }) + }, LOAD_COMPLETE_TIMEOUT_MS) + }, [clearLoadCompleteTimer, onError]) const postToPlayer = useCallback ((message: NiconicoCommand) => { const win = iframeRef.current?.contentWindow @@ -161,11 +193,12 @@ export default forwardRef ((props: Props, ref: ForwardedRef removeEventListener ('message', onMessage) - }, [onError, onLoadComplete, onMetadataChange, playerId]) + return () => removeEventListener ('message', onMessage) + }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) + + useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer]) useLayoutEffect (() => { if (!(fullScreen)) @@ -235,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef) + width={width} + height={height} + style={margedStyle} + onLoad={startLoadCompleteTimer} + allowFullScreen + allow="autoplay"/>) }) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 6c04f48..ac7f91d 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import YoutubeEmbed from 'react-youtube' import NicoViewer from '@/components/NicoViewer' @@ -8,18 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider' import type { FC, RefObject } from 'react' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' +import type { YouTubePlayer } from 'react-youtube' + +type YouTubeEvent = { + data: T + target: YouTubePlayer } type Props = { ref?: RefObject post: Post onLoadComplete?: (info: NiconicoVideoInfo) => void onMetadataChange?: (meta: NiconicoMetadata) => void + onVideoReady?: (durationMs: number) => void + onPlaybackChange?: (currentTimeMs: number) => number | void onError?: (data: unknown) => void } -const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => { +const PostEmbed: FC = ({ + ref, + post, + onLoadComplete, + onMetadataChange, + onVideoReady, + onPlaybackChange, + onError, +}) => { const dialogue = useDialogue () const [framed, setFramed] = useState (false) + const [youtubePlayer, setYoutubePlayer] = useState (null) + + const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => { + const currentTime = await player.getCurrentTime () + const currentTimeMs = currentTime * 1_000 + const targetTimeMs = onPlaybackChange?.(currentTimeMs) + + if (typeof targetTimeMs !== 'number') + return + + if (Math.abs (currentTimeMs - targetTimeMs) > 5_000) + await player.seekTo (targetTimeMs / 1_000, true) + }, [onPlaybackChange]) + + const handleYoutubeReady = async (event: YouTubeEvent) => { + setYoutubePlayer (event.target) + + try + { + await event.target.playVideo () + + const duration = await event.target.getDuration () + const durationMs = duration * 1_000 + onVideoReady?.(durationMs) + + if (!(Number.isFinite (durationMs)) || durationMs <= 0) + return + + await reportYoutubePlayback (event.target) + } + catch (error) + { + onError?.({ platform: 'youtube', error }) + } + } + + const handleYoutubeStateChange = (event: YouTubeEvent) => { + void reportYoutubePlayback (event.target) + } + + const handleYoutubeError = (event: YouTubeEvent) => { + onError?.({ platform: 'youtube', code: event.data }) + } + + const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { + onVideoReady?.(info.lengthInSeconds * 1_000) + onLoadComplete?.(info) + } + + const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => { + onPlaybackChange?.(meta.currentTime) + onMetadataChange?.(meta) + } + + useEffect (() => { + if (!(youtubePlayer) || !(onPlaybackChange)) + return + + const timer = setInterval ( + () => void reportYoutubePlayback (youtubePlayer), + 1_000) + + return () => clearInterval (timer) + }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) const url = new URL (post.url) @@ -39,8 +118,8 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onE id={videoId} width={640} height={360} - onLoadComplete={onLoadComplete} - onMetadataChange={onMetadataChange} + onLoadComplete={handleNiconicoLoadComplete} + onMetadataChange={handleNiconicoMetadataChange} onError={onError}/>) } @@ -71,7 +150,10 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onE mute: 0, loop: 1, width: '640', - height: '360' } }}/>) + height: '360' } }} + onReady={handleYoutubeReady} + onStateChange={handleYoutubeStateChange} + onError={handleYoutubeError}/>) } } diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index e3e1fb3..519820b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -21,9 +21,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC, FormEvent, ReactNode } from 'react' -import type { NiconicoMetadata, - NiconicoVideoInfo, - NiconicoViewerHandle, +import type { NiconicoViewerHandle, Post, Category, Tag, @@ -88,7 +86,9 @@ const commentBox = ( {dateString (comment.createdAt)} ), ( -
+
{programme && ( <> @@ -439,7 +439,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { void refreshProgrammes () }, [refreshProgrammes, theatreInfo.postId]) - const syncPlayback = (meta: NiconicoMetadata) => { + const syncPlaybackTime = (currentTimeMs: number): number | void => { if (!(theatreInfo.postStartedAt)) return @@ -447,24 +447,38 @@ const TheatreDetailPage: FC = ({ user }: Props) => { currentPostElapsedMs (theatreInfo), videoLength) - const drift = Math.abs (meta.currentTime - targetTime) + const drift = Math.abs (currentTimeMs - targetTime) if (drift > 5_000) embedRef.current?.seek (targetTime) + + return targetTime } const handlePlaybackError = async () => { if (!(theatreInfoRef.current.hostFlg) || loadingRef.current) return - await advancePost () + loadingRef.current = true + try + { + await advancePost () + } + finally + { + loadingRef.current = false + } } - const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { - const lengthMs = info.lengthInSeconds * 1_000 - setVideoLength (lengthMs) + const handleVideoReady = (durationMs: number) => { + const playableDurationMs = + Number.isFinite (durationMs) + ? durationMs + : 0 - if (lengthMs <= 0) + setVideoLength (playableDurationMs) + + if (playableDurationMs <= 0) { void handlePlaybackError () return @@ -738,7 +752,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { + className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950 + md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50"> {theatre && {`${ theatreTitle } | ${ SITE_TITLE }`}} @@ -811,8 +826,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { key={post.id} ref={embedRef} post={post} - onLoadComplete={handleNiconicoLoadComplete} - onMetadataChange={syncPlayback} + onVideoReady={handleVideoReady} + onPlaybackChange={syncPlaybackTime} onError={handlePlaybackError}/>) : (
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}