コミットを比較
2 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| ec7462344a | |||
| 3980e9651e |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"/>)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })}/>)
|
||||||
|
|
||||||
|
|||||||
@@ -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}/>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 ? '次の投稿を選んでゐます……' : '上映待機中'}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする