コミットを比較
5 コミット
69820265fd
...
546a212e74
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 546a212e74 | |||
| 201fe72e5a | |||
| 6e338c8616 | |||
| be2df723fe | |||
| 39d86f4778 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,14 +9,14 @@ class TheatreProgrammesController < ApplicationController
|
||||
programmes = TheatreProgramme
|
||||
.where(theatre_id: params[:theatre_id])
|
||||
.where('position > ?', position_gt)
|
||||
.includes(post: [:uploaded_user, :parents, :children,
|
||||
{ thumbnail_attachment: :blob },
|
||||
{ tags: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
|
||||
.includes(:post)
|
||||
.order(position: :desc).limit(100)
|
||||
.limit(limit)
|
||||
|
||||
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
|
||||
|
||||
@@ -6,15 +6,15 @@ class TheatreSkipEventsController < ApplicationController
|
||||
events =
|
||||
TheatreSkipEvent
|
||||
.where(theatre_id: params[:theatre_id])
|
||||
.includes(:tags, post: { tags: :tag_name })
|
||||
.includes(:post, tags: :tag_name)
|
||||
.order(created_at: :desc)
|
||||
.limit(limit)
|
||||
|
||||
render json: events.map { |event|
|
||||
{ id: event.id,
|
||||
theatre_id: event.theatre_id,
|
||||
post: PostRepr.base(event.post),
|
||||
tags: event.tags.map { |tag| TagRepr.inline(tag) },
|
||||
post: { id: event.post.id, title: event.post.title, url: event.post.url },
|
||||
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
|
||||
programme_position: event.programme_position,
|
||||
created_at: event.created_at }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -24,11 +29,9 @@ class TheatrePostSelector
|
||||
candidates = weighted_candidates
|
||||
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)),
|
||||
heaviest_posts: post_weight_json(sorted.reverse.first(limit))
|
||||
}
|
||||
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -45,17 +48,16 @@ class TheatrePostSelector
|
||||
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
|
||||
|
||||
Candidate.new(
|
||||
post:,
|
||||
penalty:,
|
||||
tags: post_tags,
|
||||
weight: 1.0 / (1.0 + penalty)
|
||||
)
|
||||
post:,
|
||||
penalty:,
|
||||
tags: post_tags,
|
||||
weight: 1.0 / (1.0 + penalty))
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -87,10 +89,8 @@ class TheatrePostSelector
|
||||
tag = tags[tag_id]
|
||||
next unless tag
|
||||
|
||||
{
|
||||
tag: light_tag_json(tag),
|
||||
penalty:
|
||||
}
|
||||
{ tag: light_tag_json(tag),
|
||||
penalty: }
|
||||
}
|
||||
.compact
|
||||
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
|
||||
@@ -98,27 +98,22 @@ class TheatrePostSelector
|
||||
|
||||
def post_weight_json candidates
|
||||
candidates.map { |candidate|
|
||||
{
|
||||
post: light_post_json(candidate.post),
|
||||
{ post: light_post_json(candidate.post),
|
||||
weight: candidate.weight,
|
||||
penalty: candidate.penalty,
|
||||
tags: candidate.tags.map { |tag| light_tag_json(tag) }
|
||||
}
|
||||
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
|
||||
}
|
||||
end
|
||||
|
||||
def light_post_json post
|
||||
{
|
||||
id: post.id,
|
||||
{ id: post.id,
|
||||
title: post.title,
|
||||
url: post.url
|
||||
}
|
||||
url: post.url }
|
||||
end
|
||||
|
||||
def light_tag_json tag
|
||||
{
|
||||
id: tag.id,
|
||||
name: tag.name
|
||||
}
|
||||
{ id: tag.id,
|
||||
name: tag.name,
|
||||
category: tag.category }
|
||||
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 "updated_at", null: false
|
||||
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"], name: "index_theatre_watching_users_on_theatre_id"
|
||||
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
||||
|
||||
@@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
|
||||
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
|
||||
|
||||
describe 'POST /theatres/:theatre_id/comments' do
|
||||
|
||||
@@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
@@ -337,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do
|
||||
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
|
||||
sign_in_as(member)
|
||||
|
||||
@@ -367,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
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
|
||||
expect(event.post).to eq(niconico_post)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
|
||||
import type { CSSProperties, ForwardedRef } from 'react'
|
||||
|
||||
@@ -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']> ()
|
||||
@@ -65,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
const styleFullScreen: CSSProperties =
|
||||
fullScreen
|
||||
? { top: 0,
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2_147_483_647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2_147_483_647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
|
||||
: { }
|
||||
|
||||
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))
|
||||
@@ -133,21 +165,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
useEffect (() => {
|
||||
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
|
||||
if (!(iframeRef.current)
|
||||
|| (event.source !== iframeRef.current.contentWindow)
|
||||
|| (event.origin !== EMBED_ORIGIN))
|
||||
return
|
||||
|| (event.source !== iframeRef.current.contentWindow)
|
||||
|| (event.origin !== EMBED_ORIGIN))
|
||||
return
|
||||
|
||||
const data = event.data
|
||||
|
||||
if (!(data)
|
||||
|| typeof data !== 'object'
|
||||
|| !('eventName' in data))
|
||||
return
|
||||
return
|
||||
|
||||
if (('playerId' in data)
|
||||
&& data.playerId
|
||||
&& data.playerId !== playerId)
|
||||
return
|
||||
return
|
||||
|
||||
if (data.eventName === 'enterProgrammaticFullScreen')
|
||||
{
|
||||
@@ -163,6 +195,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
if (data.eventName === 'loadComplete')
|
||||
{
|
||||
clearLoadCompleteTimer ()
|
||||
onLoadComplete?.(data.data.videoInfo)
|
||||
return
|
||||
}
|
||||
@@ -174,16 +207,19 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
}
|
||||
|
||||
if (data.eventName === 'error')
|
||||
{
|
||||
console.error ('niconico player error:', data)
|
||||
onError?.(data)
|
||||
}
|
||||
{
|
||||
clearLoadCompleteTimer ()
|
||||
console.error ('niconico player error:', data)
|
||||
onError?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener ('message', onMessage)
|
||||
|
||||
return () => removeEventListener ('message', onMessage)
|
||||
}, [onError, onLoadComplete, onMetadataChange, playerId])
|
||||
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
|
||||
|
||||
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
|
||||
|
||||
useLayoutEffect (() => {
|
||||
if (!(fullScreen))
|
||||
@@ -196,7 +232,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
const pollingResize = () => {
|
||||
if (ended)
|
||||
return
|
||||
return
|
||||
|
||||
const isLandscape = innerWidth >= innerHeight
|
||||
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
|
||||
@@ -210,9 +246,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
const startPollingResize = () => {
|
||||
if (requestAnimationFrame)
|
||||
requestAnimationFrame (pollingResize)
|
||||
requestAnimationFrame (pollingResize)
|
||||
else
|
||||
pollingResize ()
|
||||
pollingResize ()
|
||||
}
|
||||
|
||||
startPollingResize ()
|
||||
@@ -235,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
onLoad={startLoadCompleteTimer}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
})
|
||||
|
||||
@@ -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}/>)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { 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 (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/PostEmbed', () => ({
|
||||
default: ({ post }: {
|
||||
post: { title: string | null; url: string }
|
||||
}) => <div>Embed:{post.title || 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.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 ('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)
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
import type { FC, FormEvent, ReactNode } from 'react'
|
||||
|
||||
import type { NiconicoMetadata,
|
||||
NiconicoViewerHandle,
|
||||
import type { NiconicoViewerHandle,
|
||||
Post,
|
||||
Category,
|
||||
Tag,
|
||||
@@ -87,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">
|
||||
@@ -438,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
|
||||
|
||||
@@ -446,17 +447,44 @@ 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
|
||||
|
||||
await advancePost ()
|
||||
loadingRef.current = true
|
||||
try
|
||||
{
|
||||
await advancePost ()
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoReady = (durationMs: number) => {
|
||||
const playableDurationMs =
|
||||
Number.isFinite (durationMs)
|
||||
? durationMs
|
||||
: 0
|
||||
|
||||
setVideoLength (playableDurationMs)
|
||||
|
||||
if (playableDurationMs <= 0)
|
||||
{
|
||||
void handlePlaybackError ()
|
||||
return
|
||||
}
|
||||
|
||||
embedRef.current?.play ()
|
||||
}
|
||||
|
||||
const handleSkipVote = async () => {
|
||||
@@ -724,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>}
|
||||
@@ -797,11 +826,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
key={post.id}
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={syncPlayback}
|
||||
onVideoReady={handleVideoReady}
|
||||
onPlaybackChange={syncPlaybackTime}
|
||||
onError={handlePlaybackError}/>) : (
|
||||
<div className="grid min-h-72 place-items-center text-zinc-400">
|
||||
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
|
||||
|
||||
@@ -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 => ({
|
||||
id: 1,
|
||||
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
|
||||
updatedByUser: { id: 2, name: 'updater' },
|
||||
...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,
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする