feat: 添加关键词管理功能和会议转录编辑功能
- 在 `MeetingDetail` 页面中添加关键词选择和一键加入热词的功能 - 增加会议转录的编辑和保存功能 - 更新后端接口和相关服务,支持更新会议转录内容dev_na
parent
8dbed4c8e6
commit
ffc19fa572
|
|
@ -13,6 +13,7 @@ import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
|||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingSummaryCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.service.biz.MeetingAccessService;
|
||||
import com.imeeting.service.biz.MeetingCommandService;
|
||||
|
|
@ -244,6 +245,20 @@ public class MeetingController {
|
|||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/transcripts/{transcriptId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> updateTranscript(@PathVariable Long id,
|
||||
@PathVariable Long transcriptId,
|
||||
@RequestBody UpdateMeetingTranscriptCommand command) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||
command.setMeetingId(id);
|
||||
command.setTranscriptId(transcriptId);
|
||||
meetingCommandService.updateMeetingTranscript(command);
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/participants")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> updateParticipants(@PathVariable Long id,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
|||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -21,6 +22,8 @@ public interface MeetingCommandService {
|
|||
|
||||
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
|
||||
|
||||
void updateMeetingTranscript(UpdateMeetingTranscriptCommand command);
|
||||
|
||||
void updateMeetingBasic(UpdateMeetingBasicCommand command);
|
||||
|
||||
void updateMeetingParticipants(Long meetingId, String participants);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
|||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.HotWord;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
|
|
@ -173,6 +174,23 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateMeetingTranscript(UpdateMeetingTranscriptCommand command) {
|
||||
String content = command.getContent() == null ? "" : command.getContent().trim();
|
||||
if (content.isEmpty()) {
|
||||
throw new RuntimeException("Transcript content cannot be empty");
|
||||
}
|
||||
|
||||
int updated = transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, command.getMeetingId())
|
||||
.eq(MeetingTranscript::getId, command.getTranscriptId())
|
||||
.set(MeetingTranscript::getContent, content));
|
||||
if (updated <= 0) {
|
||||
throw new RuntimeException("Transcript not found");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateMeetingBasic(UpdateMeetingBasicCommand command) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface HotWordDTO {
|
|||
category?: string;
|
||||
weight: number;
|
||||
status: number;
|
||||
isPublic?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ export interface MeetingSpeakerUpdateDTO {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface MeetingTranscriptUpdateDTO {
|
||||
meetingId: number;
|
||||
transcriptId: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
"/api/biz/meeting/speaker",
|
||||
|
|
@ -147,6 +153,13 @@ export const updateSpeakerInfo = (params: MeetingSpeakerUpdateDTO) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const updateMeetingTranscript = (params: MeetingTranscriptUpdateDTO) => {
|
||||
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${params.meetingId}/transcripts/${params.transcriptId}`,
|
||||
params
|
||||
);
|
||||
};
|
||||
|
||||
export interface MeetingResummaryDTO {
|
||||
meetingId: number;
|
||||
summaryModelId: number;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Drawer,
|
||||
|
|
@ -49,10 +50,12 @@ import {
|
|||
MeetingVO,
|
||||
reSummary,
|
||||
updateMeetingBasic,
|
||||
updateMeetingTranscript,
|
||||
updateMeetingSummary,
|
||||
updateSpeakerInfo,
|
||||
} from '../../api/business/meeting';
|
||||
import { getAiModelDefault, getAiModelPage, AiModelVO } from '../../api/business/aimodel';
|
||||
import { getHotWordPage, saveHotWord } from '../../api/business/hotword';
|
||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||
import { listUsers } from '../../api';
|
||||
import { useDict } from '../../hooks/useDict';
|
||||
|
|
@ -428,6 +431,11 @@ const MeetingDetail: React.FC = () => {
|
|||
const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters');
|
||||
const [expandKeywords, setExpandKeywords] = useState(false);
|
||||
const [expandSummary, setExpandSummary] = useState(false);
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
||||
const [addingHotwords, setAddingHotwords] = useState(false);
|
||||
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
||||
const [transcriptDraft, setTranscriptDraft] = useState('');
|
||||
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [audioPlaying, setAudioPlaying] = useState(false);
|
||||
|
|
@ -473,6 +481,10 @@ const MeetingDetail: React.FC = () => {
|
|||
loadUsers();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
||||
}, [analysis.keywords]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return undefined;
|
||||
|
|
@ -604,6 +616,219 @@ const MeetingDetail: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeywordToggle = (keyword: string, checked: boolean) => {
|
||||
setSelectedKeywords((current) => {
|
||||
if (checked) {
|
||||
return current.includes(keyword) ? current : [...current, keyword];
|
||||
}
|
||||
return current.filter((item) => item !== keyword);
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
const handleAddSelectedHotwords = async () => {
|
||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
message.warning('请先勾选关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingHotwords(true);
|
||||
try {
|
||||
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
||||
const existingWords = new Set(
|
||||
(existingRes.data?.data?.records || [])
|
||||
.map((item) => item.word?.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
||||
|
||||
if (!toCreate.length) {
|
||||
message.info('所选关键词已全部存在于热词库');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
toCreate.map((word) =>
|
||||
saveHotWord({
|
||||
word,
|
||||
pinyinList: [],
|
||||
matchStrategy: 1,
|
||||
category: '',
|
||||
weight: 2,
|
||||
status: 1,
|
||||
isPublic: 0,
|
||||
remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const skippedCount = keywords.length - toCreate.length;
|
||||
message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`);
|
||||
setSelectedKeywords([]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setAddingHotwords(false);
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
*/
|
||||
|
||||
/*
|
||||
const handleAddSelectedHotwords = async () => {
|
||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
message.warning('请先勾选关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingHotwords(true);
|
||||
try {
|
||||
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
||||
const existingWords = new Set(
|
||||
(existingRes.data?.data?.records || [])
|
||||
.map((item) => item.word?.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
||||
|
||||
if (!toCreate.length) {
|
||||
message.info('所选关键词已全部存在于热词库');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
toCreate.map((word) =>
|
||||
saveHotWord({
|
||||
word,
|
||||
pinyinList: [],
|
||||
matchStrategy: 1,
|
||||
category: '',
|
||||
weight: 2,
|
||||
status: 1,
|
||||
isPublic: 0,
|
||||
remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const skippedCount = keywords.length - toCreate.length;
|
||||
message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`);
|
||||
setSelectedKeywords([]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setAddingHotwords(false);
|
||||
}
|
||||
};
|
||||
*/
|
||||
|
||||
const handleAddSelectedHotwords = async () => {
|
||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
message.warning('Please select keywords first');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingHotwords(true);
|
||||
try {
|
||||
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
||||
const existingWords = new Set(
|
||||
(existingRes.data?.data?.records || [])
|
||||
.map((item) => item.word?.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
||||
|
||||
if (!toCreate.length) {
|
||||
message.info('Selected keywords already exist in hot words');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
toCreate.map((word) =>
|
||||
saveHotWord({
|
||||
word,
|
||||
pinyinList: [],
|
||||
matchStrategy: 1,
|
||||
category: '',
|
||||
weight: 2,
|
||||
status: 1,
|
||||
isPublic: 0,
|
||||
remark: meeting ? `From meeting: ${meeting.title}` : 'From meeting keywords',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const skippedCount = keywords.length - toCreate.length;
|
||||
message.success(
|
||||
skippedCount > 0
|
||||
? `Added ${toCreate.length} hot words, skipped ${skippedCount} duplicates`
|
||||
: `Added ${toCreate.length} hot words`,
|
||||
);
|
||||
setSelectedKeywords([]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setAddingHotwords(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditTranscript = (item: MeetingTranscriptVO, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setEditingTranscriptId(item.id);
|
||||
setTranscriptDraft(item.content || '');
|
||||
};
|
||||
|
||||
const handleCancelEditTranscript = (event?: React.SyntheticEvent) => {
|
||||
event?.stopPropagation();
|
||||
setEditingTranscriptId(null);
|
||||
setTranscriptDraft('');
|
||||
};
|
||||
|
||||
const handleSaveTranscript = async (item: MeetingTranscriptVO, nextContent?: string) => {
|
||||
const content = (nextContent ?? transcriptDraft).trim();
|
||||
if (!content) {
|
||||
message.warning('转录内容不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meeting) return;
|
||||
if (content === (item.content || '').trim()) {
|
||||
handleCancelEditTranscript();
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingTranscriptId(item.id);
|
||||
try {
|
||||
await updateMeetingTranscript({
|
||||
meetingId: meeting.id,
|
||||
transcriptId: item.id,
|
||||
content,
|
||||
});
|
||||
message.success('原文已更新,如需同步摘要请重新总结');
|
||||
handleCancelEditTranscript();
|
||||
await fetchData(meeting.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSavingTranscriptId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranscriptDraftKeyDown = (item: MeetingTranscriptVO, event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCancelEditTranscript();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleSaveTranscript(item);
|
||||
}
|
||||
};
|
||||
|
||||
const seekTo = (timeMs: number) => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = timeMs / 1000;
|
||||
|
|
@ -783,6 +1008,18 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="summary-title">
|
||||
<RobotOutlined />
|
||||
<span>智能速览</span>
|
||||
{isOwner && analysis.keywords.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
disabled={!selectedKeywords.length}
|
||||
loading={addingHotwords}
|
||||
onClick={handleAddSelectedHotwords}
|
||||
>
|
||||
一键加入热词
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-actions">
|
||||
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
|
||||
|
|
@ -797,7 +1034,12 @@ const MeetingDetail: React.FC = () => {
|
|||
<>
|
||||
<div className="record-tags">
|
||||
{visibleKeywords.map((tag) => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
<label key={tag} className={`tag selectable-tag ${selectedKeywords.includes(tag) ? 'selected' : ''}`}>
|
||||
{isOwner && (
|
||||
<Checkbox checked={selectedKeywords.includes(tag)} onChange={(event) => handleKeywordToggle(tag, event.target.checked)} />
|
||||
)}
|
||||
<span>{tag}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{analysis.keywords.length > 9 && (
|
||||
|
|
@ -973,7 +1215,50 @@ const MeetingDetail: React.FC = () => {
|
|||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="transcript-bubble">{item.content}</div>
|
||||
{editingTranscriptId === item.id ? (
|
||||
<div
|
||||
className="transcript-bubble transcript-bubble-editing"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoFocus
|
||||
value={transcriptDraft}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(event) => setTranscriptDraft(event.target.value)}
|
||||
onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent<HTMLDivElement>)}
|
||||
onBlur={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSaveTranscript(item, event.target.value);
|
||||
}}
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
className="transcript-bubble-input"
|
||||
bordered={false}
|
||||
/>
|
||||
{false && false && <div className="transcript-bubble-actions">
|
||||
<Text type="secondary" className="transcript-bubble-hint">
|
||||
Ctrl+Enter 保存,Esc 取消
|
||||
</Text>
|
||||
<Button size="small" onClick={handleCancelEditTranscript}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={savingTranscriptId === item.id}
|
||||
onClick={() => handleSaveTranscript(item)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
||||
onDoubleClick={isOwner ? (event) => handleStartEditTranscript(item, event) : undefined}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
|
|
@ -1178,6 +1463,17 @@ const MeetingDetail: React.FC = () => {
|
|||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.selectable-tag {
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.selectable-tag.selected {
|
||||
border-color: rgba(106, 114, 255, 0.28);
|
||||
background: rgba(107, 115, 255, 0.14);
|
||||
}
|
||||
.selectable-tag .ant-checkbox {
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
.summary-copy {
|
||||
color: #465072;
|
||||
font-size: 14px;
|
||||
|
|
@ -1415,13 +1711,13 @@ const MeetingDetail: React.FC = () => {
|
|||
background: rgba(218, 223, 243, 0.96);
|
||||
}
|
||||
.transcript-entry {
|
||||
direction: ltr;
|
||||
justify-self: start;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.transcript-meta {
|
||||
|
|
@ -1443,9 +1739,11 @@ const MeetingDetail: React.FC = () => {
|
|||
cursor: pointer;
|
||||
}
|
||||
.transcript-bubble {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
max-width: min(100%, 860px);
|
||||
display: block;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
|
|
@ -1455,6 +1753,58 @@ const MeetingDetail: React.FC = () => {
|
|||
line-height: 1.86;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.transcript-bubble.editable {
|
||||
cursor: text;
|
||||
}
|
||||
.transcript-bubble.editable:hover {
|
||||
border-color: rgba(100, 112, 255, 0.24);
|
||||
}
|
||||
.transcript-bubble-editing {
|
||||
display: block;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
.transcript-bubble-input {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
.transcript-bubble-input .ant-input-affix-wrapper,
|
||||
.transcript-bubble-input .ant-input-textarea,
|
||||
.transcript-bubble-editing .ant-input-textarea,
|
||||
.transcript-bubble-editing .ant-input-textarea-show-count {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
.transcript-bubble-input .ant-input {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
min-height: 1.86em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #3f496a;
|
||||
line-height: 1.86;
|
||||
white-space: pre-wrap;
|
||||
resize: none;
|
||||
box-shadow: none;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.transcript-bubble-input .ant-input:focus,
|
||||
.transcript-bubble-input .ant-input:focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
.transcript-bubble-actions {
|
||||
display: none;
|
||||
}
|
||||
.transcript-bubble-hint {
|
||||
display: none;
|
||||
}
|
||||
.transcript-player {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
|
|
|||
Loading…
Reference in New Issue