コミットを比較

..

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ ec7462344a #358 2026-06-07 09:06:25 +09:00
みてるぞ 3980e9651e 上映会改修 (#302) (#357)
Reviewed-on: #357
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-07 02:51:25 +09:00
17個のファイルの変更857行の追加97行の削除
+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.
+4 -4
ファイルの表示
@@ -9,14 +9,14 @@ class TheatreProgrammesController < ApplicationController
programmes = TheatreProgramme programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt) .where('position > ?', position_gt)
.includes(post: [:uploaded_user, :parents, :children, .includes(:post)
{ thumbnail_attachment: :blob },
{ tags: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
.order(position: :desc).limit(100) .order(position: :desc).limit(100)
.limit(limit) .limit(limit)
render json: programmes.map { |programme| render json: programmes.map { |programme|
programme.as_json.merge(post: PostRepr.base(programme.post)) programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
} }
end end
end end
+3 -3
ファイルの表示
@@ -6,15 +6,15 @@ class TheatreSkipEventsController < ApplicationController
events = events =
TheatreSkipEvent TheatreSkipEvent
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.includes(:tags, post: { tags: :tag_name }) .includes(:post, tags: :tag_name)
.order(created_at: :desc) .order(created_at: :desc)
.limit(limit) .limit(limit)
render json: events.map { |event| render json: events.map { |event|
{ id: event.id, { id: event.id,
theatre_id: event.theatre_id, theatre_id: event.theatre_id,
post: PostRepr.base(event.post), post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| TagRepr.inline(tag) }, tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position, programme_position: event.programme_position,
created_at: event.created_at } created_at: event.created_at }
} }
+18 -23
ファイルの表示
@@ -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
@@ -24,11 +29,9 @@ class TheatrePostSelector
candidates = weighted_candidates candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] } sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ { tag_penalties: tag_penalty_json,
tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)), lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
}
end end
private private
@@ -48,14 +51,13 @@ class TheatrePostSelector
post:, post:,
penalty:, penalty:,
tags: post_tags, tags: post_tags,
weight: 1.0 / (1.0 + penalty) weight: 1.0 / (1.0 + penalty))
)
end end
end end
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
@@ -87,10 +89,8 @@ class TheatrePostSelector
tag = tags[tag_id] tag = tags[tag_id]
next unless tag next unless tag
{ { tag: light_tag_json(tag),
tag: light_tag_json(tag), penalty: }
penalty:
}
} }
.compact .compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] } .sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
@@ -98,27 +98,22 @@ class TheatrePostSelector
def post_weight_json candidates def post_weight_json candidates
candidates.map { |candidate| candidates.map { |candidate|
{ { post: light_post_json(candidate.post),
post: light_post_json(candidate.post),
weight: candidate.weight, weight: candidate.weight,
penalty: candidate.penalty, penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
} }
end end
def light_post_json post def light_post_json post
{ { id: post.id,
id: post.id,
title: post.title, title: post.title,
url: post.url url: post.url }
}
end end
def light_tag_json tag def light_tag_json tag
{ { id: tag.id,
id: tag.id, name: tag.name,
name: tag.name category: tag.category }
}
end end
end end
生成ファイル
-1
ファイルの表示
@@ -339,7 +339,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
+20
ファイルの表示
@@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
end end
it '削除済みコメントは deleted として返し、本文を隠す' do
comment_2.discard!
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
deleted_comment = response.parsed_body.find { _1['no'] == 2 }
expect(deleted_comment).to include(
'deleted' => true,
'content' => nil
)
visible_comment = response.parsed_body.find { _1['no'] == 3 }
expect(visible_comment).to include(
'deleted' => false,
'content' => 'third comment'
)
end
end end
describe 'POST /theatres/:theatre_id/comments' do describe 'POST /theatres/:theatre_id/comments' do
+7
ファイルの表示
@@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first']) expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url) expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
end end
it 'filters programmes by position_gt' do
get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 }
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2])
end
end end
end end
+44 -2
ファイルの表示
@@ -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,
@@ -337,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do
end end
end end
it 'returns 401 when not logged in' do
sign_out
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 422 when post_id is invalid' do
sign_in_as(member)
expect {
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'records a vote and returns the current vote status before majority' do it 'records a vote and returns the current vote status before majority' do
sign_in_as(member) sign_in_as(member)
@@ -367,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(true) expect(json['skipped']).to eq(true)
expect(json['post_id']).to eq(second_niconico_post.id) expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id'])
event = TheatreSkipEvent.last event = TheatreSkipEvent.last
expect(event.post).to eq(niconico_post) expect(event.post).to eq(niconico_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.
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+49 -7
ファイルの表示
@@ -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
@@ -45,7 +56,10 @@ 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']> ()
@@ -80,6 +94,24 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
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
if (!(win)) if (!(win))
@@ -97,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const seek = useCallback ((time: number) => { const seek = useCallback ((time: number) => {
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } }) postToPlayer (
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const mute = useCallback (() => { const mute = useCallback (() => {
@@ -163,18 +197,23 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete') if (data.eventName === 'loadComplete')
{ {
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo) onLoadComplete?.(data.data.videoInfo)
return return
} }
if (data.eventName === 'playerMetadataChange') if (data.eventName === 'playerMetadataChange')
{ {
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data) onMetadataChange?.(data.data)
return return
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{ {
clearLoadCompleteTimer ()
console.error ('niconico player error:', data) console.error ('niconico player error:', data)
onError?.(data) onError?.(data)
} }
@@ -183,7 +222,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
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))
@@ -238,6 +279,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
width={width} width={width}
height={height} height={height}
style={margedStyle} style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen allowFullScreen
allow="autoplay"/>) allow="autoplay"/>)
}) })
+66 -1
ファイルの表示
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (), confirm: vi.fn (),
})) }))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
vi.mock ('@/components/NicoViewer', () => ({ vi.mock ('@/components/NicoViewer', () => ({
default: ({ id }: { id: string }) => <div>Nico:{id}</div>, default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
})) }))
vi.mock ('react-youtube', () => ({ vi.mock ('react-youtube', () => ({
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument () expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
}) })
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => { it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>) render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
+103 -5
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useCallback, useEffect, useRef, 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,113 @@ 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 niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
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) => {
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
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 +134,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 +166,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}/>)
} }
} }
+298
ファイルの表示
@@ -0,0 +1,298 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost,
buildTheatre,
buildTheatreComment,
buildTheatreInfo,
buildTheatrePostSelectionWeights,
buildTheatreProgramme,
buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiDelete: vi.fn (),
apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPost: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (() => false),
}))
const postsApi = vi.hoisted (() => ({
fetchPost: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const postEmbed = vi.hoisted (() => ({
props: vi.fn (),
play: vi.fn (),
seek: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
post: { title: string | null; url: string }
}) => {
postEmbed.props (props)
if (props.ref)
props.ref.current = {
play: postEmbed.play,
seek: postEmbed.seek,
}
return <div>Embed:{props.post.title || props.post.url}</div>
},
}))
vi.mock ('@/components/PostEditForm', () => ({
default: () => <div>Post edit form</div>,
}))
vi.mock ('framer-motion', () => ({
motion: {
aside: ({ children }: { children?: ReactNode }) => <aside>{children}</aside>,
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main>,
},
}))
const currentPost = buildPost ({
id: 10,
title: '上映中の投稿',
url: 'https://www.nicovideo.jp/watch/sm10',
})
const theatre = buildTheatre ({ id: 7, name: '上映室' })
const programme = buildTheatreProgramme ({
theatreId: 7,
position: 3,
post: currentPost,
})
const weights = buildTheatrePostSelectionWeights ({
lightestPosts: [{
post: currentPost,
penalty: 2,
weight: 0.5,
tags: [],
}],
})
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders (
<Routes>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
</Routes>,
{ route: '/theatres/7' },
)
const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7':
return Promise.resolve (theatre)
case '/theatres/7/comments':
return Promise.resolve ([
buildTheatreComment ({
theatreId: 7,
no: 2,
user: { id: 1, name: 'tester' },
content: '視聴コメント',
}),
])
case '/theatres/7/programmes':
return Promise.resolve ([programme])
case '/theatres/7/post_selection_weights':
return Promise.resolve (weights)
default:
return Promise.reject (new Error (`Unexpected GET ${ path }`))
}
})
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
case '/theatres/7/skip_vote':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: 1,
voted: true,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
api.apiDelete.mockResolvedValue (undefined)
api.apiPatch.mockResolvedValue (undefined)
api.apiPost.mockResolvedValue (undefined)
postsApi.fetchPost.mockResolvedValue (currentPost)
dialogue.confirm.mockResolvedValue (true)
}
describe ('TheatreDetailPage', () => {
beforeEach (() => {
vi.useRealTimers ()
vi.clearAllMocks ()
mockDefaultApi ()
})
it ('loads theatre state, comments, current post, programme history, and weights', async () => {
renderPage ()
expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
})
})
it ('votes to skip the current post', async () => {
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/theatres/7/skip_vote',
{ post_id: 10 },
)
})
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
.toBeInTheDocument ()
})
it ('does not seek to zero while applying video length from the player', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 7_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
const props = postEmbed.props.mock.calls.at (-1)![0]
act (() => {
props.onVideoReady (120_000)
props.onPlaybackChange (0)
})
const seekMs = postEmbed.seek.mock.calls[0][0]
expect (seekMs).toBeGreaterThanOrEqual (7_000)
expect (seekMs).toBeLessThan (10_000)
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
})
it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 4_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching')
})
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledTimes (2)
}, { timeout: 2_500 })
expect (api.apiPatch).not.toHaveBeenCalledWith ('/theatres/7/next_post')
})
it ('deletes an owned comment after confirmation', async () => {
renderPage ()
fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
await waitFor (() => {
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
})
expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
})
})
+43 -12
ファイルの表示
@@ -21,8 +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,
NiconicoViewerHandle,
Post, Post,
Category, Category,
Tag, Tag,
@@ -87,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">
@@ -438,26 +439,58 @@ 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
const currentVideoLength = videoLengthRef.current
if (currentVideoLength <= 0)
return
const targetTime = Math.min ( const targetTime = Math.min (
currentPostElapsedMs (theatreInfo), currentPostElapsedMs (theatreInfo),
videoLength) currentVideoLength)
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
loadingRef.current = true
try
{
await advancePost () await advancePost ()
} }
finally
{
loadingRef.current = false
}
}
const handleVideoReady = (durationMs: number) => {
const playableDurationMs =
Number.isFinite (durationMs)
? durationMs
: 0
setVideoLength (playableDurationMs)
videoLengthRef.current = playableDurationMs
if (playableDurationMs <= 0)
{
void handlePlaybackError ()
return
}
embedRef.current?.play ()
}
const handleSkipVote = async () => { const handleSkipVote = async () => {
if (!(id) || !(post)) if (!(id) || !(post))
@@ -724,7 +757,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>}
@@ -797,11 +831,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
key={post.id} key={post.id}
ref={embedRef} ref={embedRef}
post={post} post={post}
onLoadComplete={info => { onVideoReady={handleVideoReady}
embedRef.current?.play () onPlaybackChange={syncPlaybackTime}
setVideoLength (info.lengthInSeconds * 1_000)
}}
onMetadataChange={syncPlayback}
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 ? '次の投稿を選んでゐます……' : '上映待機中'}
+69 -1
ファイルの表示
@@ -1,4 +1,13 @@
import type { Material, Post, Tag, User, WikiPage } from '@/types' import type { Material,
Post,
Tag,
Theatre,
TheatreComment,
TheatreInfo,
TheatrePostSelectionWeights,
TheatreProgramme,
User,
WikiPage } from '@/types'
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 1, id: 1,
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
updatedByUser: { id: 2, name: 'updater' }, updatedByUser: { id: 2, name: 'updater' },
...overrides, ...overrides,
}) })
export const buildTheatre = (overrides: Partial<Theatre> = {}): Theatre => ({
id: 1,
name: 'テスト劇場',
opensAt: '2026-01-02T03:04:05.000Z',
closesAt: null,
createdByUser: { id: 1, name: 'creator' },
createdAt: '2026-01-02T03:04:05.000Z',
updatedAt: '2026-01-03T03:04:05.000Z',
...overrides,
})
export const buildTheatreInfo = (
overrides: Partial<TheatreInfo> = {},
): TheatreInfo => ({
hostFlg: false,
postId: null,
postStartedAt: null,
postElapsedMs: null,
watchingUsers: [],
skipVote: {
votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false,
},
...overrides,
})
export const buildTheatreComment = (
overrides: Partial<TheatreComment> = {},
): TheatreComment => ({
theatreId: 1,
no: 1,
deleted: false,
user: { id: 1, name: 'tester' },
content: 'テストコメント',
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
} as TheatreComment)
export const buildTheatreProgramme = (
overrides: Partial<TheatreProgramme> = {},
): TheatreProgramme => ({
theatreId: 1,
position: 1,
post: buildPost (),
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
})
export const buildTheatrePostSelectionWeights = (
overrides: Partial<TheatrePostSelectionWeights> = {},
): TheatrePostSelectionWeights => ({
tagPenalties: [],
lightestPosts: [],
heaviestPosts: [],
...overrides,
})