上映会改修 (#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.
- 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.
+4
ファイルの表示
@@ -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.
+6 -1
ファイルの表示
@@ -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
+25 -1
ファイルの表示
@@ -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,
+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.
- 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.
+43 -6
ファイルの表示
@@ -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
@@ -45,7 +56,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
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 [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -80,6 +94,24 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const margedStyle: CSSProperties =
{ 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 win = iframeRef.current?.contentWindow
if (!(win))
@@ -163,6 +195,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete')
{
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo)
return
}
@@ -175,6 +208,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'error')
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
@@ -183,7 +217,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage)
}, [onError, onLoadComplete, onMetadataChange, playerId])
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => {
if (!(fullScreen))
@@ -238,6 +274,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
width={width}
height={height}
style={margedStyle}
onLoad={startLoadCompleteTimer}
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 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<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = {
ref?: RefObject<NiconicoViewerHandle | null>
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<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => {
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue ()
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)
@@ -39,8 +118,8 @@ const PostEmbed: FC<Props> = ({ 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<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onE
mute: 0,
loop: 1,
width: '640',
height: '360' } }}/>)
height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
}
}
+28 -13
ファイルの表示
@@ -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)}
</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 && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
@@ -439,7 +439,7 @@ const TheatreDetailPage: FC<Props> = ({ 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<Props> = ({ 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
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<Props> = ({ user }: Props) => {
<motion.div
layout="position"
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>
<meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
@@ -811,8 +826,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={syncPlayback}
onVideoReady={handleVideoReady}
onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}