上映会改修 (#302) #357

マージ済み
みてるぞ が 13 個のコミットを feature/302 から main へマージ 2026-06-07 02:51:26 +09:00
8個のファイルの変更222行の追加47行の削除
コミット be2df723fe の変更だけを表示してゐます - すべてのコミットを表示
+4
ファイルの表示
@@ -114,6 +114,10 @@ npm run preview
requires a break. requires a break.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation. 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 - TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid. syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters. - Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
+4
ファイルの表示
@@ -83,6 +83,10 @@ service, representation, and spec.
by 4 spaces. by 4 spaces.
- Put one logical pair per line when the expression would otherwise become - Put one logical pair per line when the expression would otherwise become
dense. 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. - For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code. - Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval. - Do not add production dependencies without approval.
+6 -1
ファイルの表示
@@ -1,5 +1,10 @@
class TheatrePostSelector class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true) 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: def initialize theatre:
@theatre = theatre @theatre = theatre
@@ -52,7 +57,7 @@ class TheatrePostSelector
end end
def eligible_posts 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 = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts posts
end end
+25 -1
ファイルの表示
@@ -28,6 +28,13 @@ RSpec.describe 'Theatres API', type: :request do
) )
end end
let!(:youtube_post) do
Post.create!(
title: 'youtube post',
url: 'https://www.youtube.com/watch?v=yt123'
)
end
let!(:other_post) do let!(:other_post) do
Post.create!( Post.create!(
title: 'other post', title: 'other post',
@@ -286,7 +293,7 @@ RSpec.describe 'Theatres API', type: :request do
.to change { theatre.reload.current_post_id } .to change { theatre.reload.current_post_id }
expect(response).to have_http_status(:no_content) 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) .to include(theatre.reload.current_post_id)
expect(theatre.reload.current_post_started_at) expect(theatre.reload.current_post_started_at)
.to be_within(1.second).of(Time.current) .to be_within(1.second).of(Time.current)
@@ -294,10 +301,27 @@ RSpec.describe 'Theatres API', type: :request do
end end
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 context 'when current user is host and no eligible post exists' do
before do before do
niconico_post.destroy! niconico_post.destroy!
second_niconico_post.destroy! second_niconico_post.destroy!
youtube_post.destroy!
theatre.update!( theatre.update!(
host_user: member, host_user: member,
current_post: other_post, current_post: other_post,
+4
ファイルの表示
@@ -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. - Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical. - Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX. - 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 - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes. to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces. - Treat one leading tab as exactly equivalent to 8 leading spaces.
+63 -26
ファイルの表示
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage = type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } } | { eventName: 'loadComplete'
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata } playerId?: string
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown } data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string } | { 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 = type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string } | { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -30,6 +40,7 @@ type NiconicoCommand =
data: { commentVisibility: boolean } } data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp' const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = { type Props = {
id: string id: string
@@ -42,10 +53,13 @@ type Props = {
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => { export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -77,8 +91,26 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' } WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { } : { }
const margedStyle: CSSProperties = const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen } { border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
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 postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow const win = iframeRef.current?.contentWindow
@@ -161,11 +193,12 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
return return
} }
if (data.eventName === 'loadComplete') if (data.eventName === 'loadComplete')
{ {
onLoadComplete?.(data.data.videoInfo) clearLoadCompleteTimer ()
return onLoadComplete?.(data.data.videoInfo)
} return
}
if (data.eventName === 'playerMetadataChange') if (data.eventName === 'playerMetadataChange')
{ {
@@ -173,17 +206,20 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
return return
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{ {
console.error ('niconico player error:', data) clearLoadCompleteTimer ()
onError?.(data) console.error ('niconico player error:', data)
} onError?.(data)
}
} }
addEventListener ('message', onMessage) addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage)
}, [onError, onLoadComplete, onMetadataChange, playerId]) }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
@@ -235,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={src} src={src}
width={width} width={width}
height={height} height={height}
style={margedStyle} style={margedStyle}
allowFullScreen onLoad={startLoadCompleteTimer}
allow="autoplay"/>) allowFullScreen
allow="autoplay"/>)
}) })
+87 -5
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import YoutubeEmbed from 'react-youtube' import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
@@ -8,18 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = { type Props = {
ref?: RefObject<NiconicoViewerHandle | null> ref?: RefObject<NiconicoViewerHandle | null>
post: Post post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void onMetadataChange?: (meta: NiconicoMetadata) => void
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void } onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => { const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (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<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
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) const url = new URL (post.url)
@@ -39,8 +118,8 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onE
id={videoId} id={videoId}
width={640} width={640}
height={360} height={360}
onLoadComplete={onLoadComplete} onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={onMetadataChange} onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>) onError={onError}/>)
} }
@@ -71,7 +150,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onE
mute: 0, mute: 0,
loop: 1, loop: 1,
width: '640', width: '640',
height: '360' } }}/>) height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
} }
} }
+29 -14
ファイルの表示
@@ -21,9 +21,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent, ReactNode } from 'react' import type { FC, FormEvent, ReactNode } from 'react'
import type { NiconicoMetadata, import type { NiconicoViewerHandle,
NiconicoVideoInfo,
NiconicoViewerHandle,
Post, Post,
Category, Category,
Tag, Tag,
@@ -88,7 +86,9 @@ const commentBox = (
{dateString (comment.createdAt)} {dateString (comment.createdAt)}
</div>), </div>),
( (
<div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400"> <div
key={`${ comment.no }-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && ( {programme && (
<> <>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline"> <PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
@@ -439,7 +439,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
void refreshProgrammes () void refreshProgrammes ()
}, [refreshProgrammes, theatreInfo.postId]) }, [refreshProgrammes, theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => { const syncPlaybackTime = (currentTimeMs: number): number | void => {
if (!(theatreInfo.postStartedAt)) if (!(theatreInfo.postStartedAt))
return return
@@ -447,24 +447,38 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
currentPostElapsedMs (theatreInfo), currentPostElapsedMs (theatreInfo),
videoLength) videoLength)
const drift = Math.abs (meta.currentTime - targetTime) const drift = Math.abs (currentTimeMs - targetTime)
if (drift > 5_000) if (drift > 5_000)
embedRef.current?.seek (targetTime) embedRef.current?.seek (targetTime)
return targetTime
} }
const handlePlaybackError = async () => { const handlePlaybackError = async () => {
if (!(theatreInfoRef.current.hostFlg) || loadingRef.current) if (!(theatreInfoRef.current.hostFlg) || loadingRef.current)
return return
await advancePost () loadingRef.current = true
try
{
await advancePost ()
}
finally
{
loadingRef.current = false
}
} }
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { const handleVideoReady = (durationMs: number) => {
const lengthMs = info.lengthInSeconds * 1_000 const playableDurationMs =
setVideoLength (lengthMs) Number.isFinite (durationMs)
? durationMs
: 0
if (lengthMs <= 0) setVideoLength (playableDurationMs)
if (playableDurationMs <= 0)
{ {
void handlePlaybackError () void handlePlaybackError ()
return return
@@ -738,7 +752,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<motion.div <motion.div
layout="position" layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
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"> 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">
<Helmet> <Helmet>
<meta name="robots" content="noindex"/> <meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>} {theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
@@ -811,8 +826,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
key={post.id} key={post.id}
ref={embedRef} ref={embedRef}
post={post} post={post}
onLoadComplete={handleNiconicoLoadComplete} onVideoReady={handleVideoReady}
onMetadataChange={syncPlayback} onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : ( onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400"> <div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'} {loading ? '次の投稿を選んでゐます……' : '上映待機中'}