kopia lustrzana https://codeberg.org/nmkj/audon
support speaker request
rodzic
544b0a54c1
commit
ac16bc409a
|
@ -9,6 +9,7 @@ export const validators = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function webfinger(user) {
|
export function webfinger(user) {
|
||||||
|
if (!user) return "";
|
||||||
const url = new URL(user.url);
|
const url = new URL(user.url);
|
||||||
return `${user.acct}@${url.host}`;
|
return `${user.acct}@${url.host}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,21 +14,26 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isHostOrCohost() {
|
canSpeak() {
|
||||||
return this.type === "host" || this.type === "cohost";
|
return this.type === "host" || this.type === "cohost" || this.type === "speaker";
|
||||||
},
|
},
|
||||||
badgeProps() {
|
badgeProps() {
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
case "host":
|
case "host":
|
||||||
return {
|
return {
|
||||||
content: "Host",
|
content: "Host",
|
||||||
colour: "primary",
|
colour: "deep-orange",
|
||||||
};
|
};
|
||||||
case "cohost":
|
case "cohost":
|
||||||
return {
|
return {
|
||||||
content: "Cohost",
|
content: "Cohost",
|
||||||
colour: "secondary",
|
colour: "indigo",
|
||||||
};
|
};
|
||||||
|
case "speaker":
|
||||||
|
return {
|
||||||
|
content: "Speaker",
|
||||||
|
colour: ""
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
content: "",
|
content: "",
|
||||||
|
@ -43,7 +48,7 @@ export default {
|
||||||
<template>
|
<template>
|
||||||
<v-col sm="3" cols="4" class="text-center">
|
<v-col sm="3" cols="4" class="text-center">
|
||||||
<v-badge
|
<v-badge
|
||||||
v-if="isHostOrCohost"
|
v-if="canSpeak"
|
||||||
:content="badgeProps.content"
|
:content="badgeProps.content"
|
||||||
location="top"
|
location="top"
|
||||||
:color="badgeProps.colour"
|
:color="badgeProps.colour"
|
||||||
|
@ -59,8 +64,8 @@ export default {
|
||||||
>
|
>
|
||||||
<v-img :src="data?.avatar"></v-img>
|
<v-img :src="data?.avatar"></v-img>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<h4 :class="isHostOrCohost ? 'mt-1' : 'mt-2'">
|
<h4 :class="canSpeak ? 'mt-1' : 'mt-2'">
|
||||||
<v-icon v-if="isHostOrCohost" :icon="muted ? mdiMicrophoneOff : mdiMicrophone"></v-icon>
|
<v-icon v-if="canSpeak" :icon="muted ? mdiMicrophoneOff : mdiMicrophone"></v-icon>
|
||||||
<a :href="data?.url" target="_blank">{{ data?.displayName }}</a>
|
<a :href="data?.url" target="_blank">{{ data?.displayName }}</a>
|
||||||
</h4>
|
</h4>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
|
@ -239,6 +239,8 @@ export default {
|
||||||
v-model="searchError.enabled"
|
v-model="searchError.enabled"
|
||||||
color="error"
|
color="error"
|
||||||
:timeout="searchError.timeout"
|
:timeout="searchError.timeout"
|
||||||
|
position="sticky"
|
||||||
|
location="top"
|
||||||
>
|
>
|
||||||
{{ searchError.message }}
|
{{ searchError.message }}
|
||||||
</v-snackbar>
|
</v-snackbar>
|
||||||
|
@ -276,8 +278,8 @@ export default {
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
<v-card class="mt-3" variant="outlined">
|
<v-card class="mt-3" variant="outlined">
|
||||||
<v-card-title class="text-subtitle-1">共同ホスト</v-card-title>
|
<v-card-title class="text-subtitle-1">共同ホスト</v-card-title>
|
||||||
<v-card-text v-if="cohosts.length > 0 || searchResult">
|
<v-card-text v-if="cohosts.length > 0 || searchResult" class="py-0">
|
||||||
<div v-if="cohosts.length > 0">
|
<template v-if="cohosts.length > 0">
|
||||||
<v-list lines="two" variant="tonal">
|
<v-list lines="two" variant="tonal">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(cohost, index) in cohosts"
|
v-for="(cohost, index) in cohosts"
|
||||||
|
@ -308,8 +310,8 @@ export default {
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="searchResult">
|
<template v-if="searchResult">
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-list lines="two">
|
<v-list lines="two">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
|
@ -335,7 +337,7 @@ export default {
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
|
|
@ -30,9 +30,6 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
lastPath() {
|
|
||||||
return this.$route.query.l ?? "";
|
|
||||||
},
|
|
||||||
serverErrors() {
|
serverErrors() {
|
||||||
const errors = this.v$.server.$errors;
|
const errors = this.v$.server.$errors;
|
||||||
const messages = map(errors, (e) => e.$message);
|
const messages = map(errors, (e) => e.$message);
|
||||||
|
@ -50,7 +47,7 @@ export default {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.postForm("/app/login", {
|
const response = await axios.postForm("/app/login", {
|
||||||
redir: this.lastPath,
|
redir: this.$route.query.l ?? "/",
|
||||||
server: this.server,
|
server: this.server,
|
||||||
});
|
});
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { pushNotFound } from "../assets/utils";
|
import { pushNotFound, webfinger } from "../assets/utils";
|
||||||
import { useMastodonStore } from "../stores/mastodon";
|
import { useMastodonStore } from "../stores/mastodon";
|
||||||
import { map, some, omit } from "lodash-es";
|
import { map, some, omit, filter } from "lodash-es";
|
||||||
import Participant from "../components/Participant.vue";
|
import Participant from "../components/Participant.vue";
|
||||||
import {
|
import {
|
||||||
mdiMicrophone,
|
mdiMicrophone,
|
||||||
|
@ -11,14 +11,25 @@ import {
|
||||||
mdiMicrophoneQuestion,
|
mdiMicrophoneQuestion,
|
||||||
mdiDoorClosed,
|
mdiDoorClosed,
|
||||||
mdiVolumeOff,
|
mdiVolumeOff,
|
||||||
|
mdiClose,
|
||||||
|
mdiCheck,
|
||||||
|
mdiAccountVoice,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import { Room, RoomEvent, Track, DisconnectReason } from "livekit-client";
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
DisconnectReason,
|
||||||
|
DataPacket_Kind,
|
||||||
|
} from "livekit-client";
|
||||||
import { login } from "masto";
|
import { login } from "masto";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
setup() {
|
setup() {
|
||||||
return {
|
return {
|
||||||
donStore: useMastodonStore(),
|
donStore: useMastodonStore(),
|
||||||
|
decoder: new TextDecoder(),
|
||||||
|
encoder: new TextEncoder(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
@ -26,12 +37,15 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
mdiAccountVoice,
|
||||||
mdiMicrophone,
|
mdiMicrophone,
|
||||||
mdiMicrophoneOff,
|
mdiMicrophoneOff,
|
||||||
mdiPhoneRemove,
|
mdiPhoneRemove,
|
||||||
mdiMicrophoneQuestion,
|
mdiMicrophoneQuestion,
|
||||||
mdiDoorClosed,
|
mdiDoorClosed,
|
||||||
mdiVolumeOff,
|
mdiVolumeOff,
|
||||||
|
mdiClose,
|
||||||
|
mdiCheck,
|
||||||
roomID: this.$route.params.id,
|
roomID: this.$route.params.id,
|
||||||
loading: false,
|
loading: false,
|
||||||
mainHeight: 600,
|
mainHeight: 600,
|
||||||
|
@ -41,6 +55,7 @@ export default {
|
||||||
description: "",
|
description: "",
|
||||||
host: null,
|
host: null,
|
||||||
cohosts: [],
|
cohosts: [],
|
||||||
|
speakers: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
},
|
},
|
||||||
participants: {},
|
participants: {},
|
||||||
|
@ -49,6 +64,10 @@ export default {
|
||||||
mutedSpeakerIDs: new Set(),
|
mutedSpeakerIDs: new Set(),
|
||||||
micGranted: false,
|
micGranted: false,
|
||||||
autoplayDisabled: false,
|
autoplayDisabled: false,
|
||||||
|
speakRequests: new Set(),
|
||||||
|
showRequestNotification: false,
|
||||||
|
showRequestDialog: false,
|
||||||
|
showRequestedNotification: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
@ -70,7 +89,7 @@ export default {
|
||||||
iamMuted() {
|
iamMuted() {
|
||||||
const myAudonID = this.donStore.oauth.audon_id;
|
const myAudonID = this.donStore.oauth.audon_id;
|
||||||
return (
|
return (
|
||||||
(this.iamHost || this.iamCohost) &&
|
(this.iamHost || this.iamCohost || this.iamSpeaker) &&
|
||||||
this.micGranted &&
|
this.micGranted &&
|
||||||
this.mutedSpeakerIDs.has(myAudonID)
|
this.mutedSpeakerIDs.has(myAudonID)
|
||||||
);
|
);
|
||||||
|
@ -87,6 +106,12 @@ export default {
|
||||||
|
|
||||||
return this.isCohost({ remote_id: myInfo.id, remote_url: myInfo.url });
|
return this.isCohost({ remote_id: myInfo.id, remote_url: myInfo.url });
|
||||||
},
|
},
|
||||||
|
iamSpeaker() {
|
||||||
|
const myAudonID = this.donStore.oauth.audon_id;
|
||||||
|
if (!myAudonID) return false;
|
||||||
|
|
||||||
|
return this.isSpeaker(myAudonID);
|
||||||
|
},
|
||||||
micStatusIcon() {
|
micStatusIcon() {
|
||||||
if (!this.micGranted) {
|
if (!this.micGranted) {
|
||||||
return mdiMicrophoneQuestion;
|
return mdiMicrophoneQuestion;
|
||||||
|
@ -98,6 +123,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
webfinger,
|
||||||
async joinRoom() {
|
async joinRoom() {
|
||||||
if (!this.donStore.authorized) return;
|
if (!this.donStore.authorized) return;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
@ -153,8 +179,6 @@ export default {
|
||||||
})
|
})
|
||||||
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
|
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
|
||||||
if (!room.canPlaybackAudio) {
|
if (!room.canPlaybackAudio) {
|
||||||
// FIXME: popup a dialog to ask user to allow audio playback
|
|
||||||
// alert("autoplay not permitted");
|
|
||||||
self.autoplayDisabled = true;
|
self.autoplayDisabled = true;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -177,6 +201,47 @@ export default {
|
||||||
alert(message);
|
alert(message);
|
||||||
}
|
}
|
||||||
self.$router.push({ name: "home" });
|
self.$router.push({ name: "home" });
|
||||||
|
})
|
||||||
|
.on(RoomEvent.DataReceived, (payload, participant, kind) => {
|
||||||
|
try {
|
||||||
|
/* data should be like
|
||||||
|
{ "kind": "speak_request" }
|
||||||
|
{ "kind": "chat", "data": "..." }
|
||||||
|
{ "kind": "request_declined", "audon_id": "..."}
|
||||||
|
*/
|
||||||
|
const strData = self.decoder.decode(payload);
|
||||||
|
const jsonData = JSON.parse(strData);
|
||||||
|
const metadata = JSON.parse(participant.metadata);
|
||||||
|
switch (jsonData?.kind) {
|
||||||
|
case "speak_request": // someone is wanting to be a speaker
|
||||||
|
self.onSpeakRequestReceived(participant);
|
||||||
|
break;
|
||||||
|
case "request_declined":
|
||||||
|
if (
|
||||||
|
self.isHost(participant.identity) ||
|
||||||
|
self.isCohost(metadata)
|
||||||
|
) {
|
||||||
|
self.speakRequests.delete(jsonData.audon_id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"invalida data received from: ",
|
||||||
|
participant.identity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on(RoomEvent.RoomMetadataChanged, (metadata) => {
|
||||||
|
self.roomInfo = JSON.parse(metadata);
|
||||||
|
for (const speakers of self.roomInfo.speakers) {
|
||||||
|
self.speakRequests.delete(speakers.audon_id);
|
||||||
|
}
|
||||||
|
if (self.iamSpeaker || !self.micGranted) {
|
||||||
|
self.roomClient.localParticipant.setMicrophoneEnabled(true).then((v) => {
|
||||||
|
self.micGranted = true;
|
||||||
|
})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await room.connect(resp.data.url, resp.data.token);
|
await room.connect(resp.data.url, resp.data.token);
|
||||||
this.roomClient = room;
|
this.roomClient = room;
|
||||||
|
@ -194,7 +259,7 @@ export default {
|
||||||
this.fetchMastoData(key, value);
|
this.fetchMastoData(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.iamHost || this.iamCohost) {
|
if (this.iamHost || this.iamCohost || this.iamSpeaker) {
|
||||||
try {
|
try {
|
||||||
await room.localParticipant.setMicrophoneEnabled(true);
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -241,6 +306,59 @@ export default {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
isSpeaker(identity) {
|
||||||
|
return identity && some(this.roomInfo.speakers, { audon_id: identity });
|
||||||
|
},
|
||||||
|
isTalking(identity) {
|
||||||
|
return (
|
||||||
|
this.activeSpeakerIDs.has(identity) &&
|
||||||
|
!this.mutedSpeakerIDs.has(identity)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSpeakRequestReceived(participant) {
|
||||||
|
if (this.iamHost || this.iamCohost) {
|
||||||
|
if (this.speakRequests.has(participant.identity)) return;
|
||||||
|
this.speakRequests.add(participant.identity);
|
||||||
|
this.showRequestNotification = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onAcceptRequest(identity) {
|
||||||
|
// promote user to a speaker
|
||||||
|
// the livekit server will update room metadata
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/room/${this.roomID}/${identity}`);
|
||||||
|
} catch (reqError) {
|
||||||
|
console.log("permission update request error: ", reqError);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onDeclineRequest(identity) {
|
||||||
|
// share declined identity with host and other cohosts
|
||||||
|
if (!this.speakRequests.delete(identity)) return;
|
||||||
|
const data = { kind: "request_declined", audon_id: identity };
|
||||||
|
await this.publishDataToHostAndCohosts(data);
|
||||||
|
},
|
||||||
|
async requestSpeak() {
|
||||||
|
if (confirm("通話をリクエストしますか?")) {
|
||||||
|
await this.publishDataToHostAndCohosts({ kind: "speak_request" });
|
||||||
|
this.showRequestedNotification = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async publishDataToHostAndCohosts(data) {
|
||||||
|
const payload = this.encoder.encode(JSON.stringify(data));
|
||||||
|
// participants - speakers
|
||||||
|
const hostandcohosts = filter(
|
||||||
|
Array.from(this.roomClient.participants.values()),
|
||||||
|
(p) => {
|
||||||
|
const metadata = JSON.parse(p.metadata);
|
||||||
|
return this.isHost(p.identity) || this.isCohost(metadata);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.roomClient.localParticipant.publishData(
|
||||||
|
payload,
|
||||||
|
DataPacket_Kind.RELIABLE,
|
||||||
|
hostandcohosts
|
||||||
|
);
|
||||||
|
},
|
||||||
addParticipant(participant) {
|
addParticipant(participant) {
|
||||||
const metadata = participant.metadata
|
const metadata = participant.metadata
|
||||||
? JSON.parse(participant.metadata)
|
? JSON.parse(participant.metadata)
|
||||||
|
@ -276,7 +394,7 @@ export default {
|
||||||
const myTrack = this.roomClient.localParticipant.getTrack(
|
const myTrack = this.roomClient.localParticipant.getTrack(
|
||||||
Track.Source.Microphone
|
Track.Source.Microphone
|
||||||
);
|
);
|
||||||
if (this.iamHost || this.iamCohost) {
|
if (this.iamHost || this.iamCohost || this.iamSpeaker) {
|
||||||
try {
|
try {
|
||||||
if (!this.micGranted) {
|
if (!this.micGranted) {
|
||||||
await this.roomClient.localParticipant.setMicrophoneEnabled(true);
|
await this.roomClient.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
@ -289,7 +407,8 @@ export default {
|
||||||
alert("ブラウザが録音を許可していません");
|
alert("ブラウザが録音を許可していません");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert("リクエストはアップデートで実装予定です!");
|
// alert("リクエストはアップデートで実装予定です!");
|
||||||
|
this.requestSpeak();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onRoomClose() {
|
async onRoomClose() {
|
||||||
|
@ -329,6 +448,97 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
<v-dialog persistent v-model="showRequestDialog" max-width="500">
|
||||||
|
<v-card max-height="600" class="d-flex flex-column">
|
||||||
|
<v-card-title>通話リクエスト</v-card-title>
|
||||||
|
<v-card-text class="flex-grow-1 overflow-auto py-0">
|
||||||
|
<v-list v-if="speakRequests.size > 0" lines="two" variant="tonal">
|
||||||
|
<v-list-item
|
||||||
|
v-for="id of Array.from(speakRequests)"
|
||||||
|
:key="id"
|
||||||
|
:title="cachedMastoData[id]?.displayName"
|
||||||
|
class="my-1"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar class="rounded">
|
||||||
|
<v-img :src="cachedMastoData[id]?.avatar"></v-img>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
class="mr-2"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:icon="mdiCheck"
|
||||||
|
@click="onAcceptRequest(id)"
|
||||||
|
></v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:icon="mdiClose"
|
||||||
|
@click="onDeclineRequest(id)"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<a
|
||||||
|
:href="cachedMastoData[id]?.url"
|
||||||
|
class="text-body"
|
||||||
|
style="text-decoration: inherit; color: inherit"
|
||||||
|
target="_blank"
|
||||||
|
>{{ webfinger(cachedMastoData[id]) }}</a
|
||||||
|
>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<p class="text-center py-3" v-else>リクエストはありません</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn @click="showRequestDialog = false">閉じる</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
<v-snackbar
|
||||||
|
location="top"
|
||||||
|
:timeout="5000"
|
||||||
|
v-model="showRequestedNotification"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<strong>通話リクエストを送信しました!</strong>
|
||||||
|
<template v-slot:actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showRequestedNotification = false"
|
||||||
|
:icon="mdiClose"
|
||||||
|
size="small"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
<v-snackbar
|
||||||
|
location="top"
|
||||||
|
:timeout="-1"
|
||||||
|
v-model="showRequestNotification"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="cursor: pointer"
|
||||||
|
@click="
|
||||||
|
showRequestDialog = true;
|
||||||
|
showRequestNotification = false;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<strong>新しい通話リクエストがあります</strong>
|
||||||
|
</div>
|
||||||
|
<template v-slot:actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="showRequestNotification = false"
|
||||||
|
:icon="mdiClose"
|
||||||
|
size="small"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
<div class="d-none" ref="audioDOM"></div>
|
<div class="d-none" ref="audioDOM"></div>
|
||||||
<main class="fill-height" v-resize="onResize">
|
<main class="fill-height" v-resize="onResize">
|
||||||
<v-card :height="mainHeight" :loading="loading" class="d-flex flex-column">
|
<v-card :height="mainHeight" :loading="loading" class="d-flex flex-column">
|
||||||
|
@ -361,26 +571,34 @@ export default {
|
||||||
<template v-for="(value, key) of participants" :key="key">
|
<template v-for="(value, key) of participants" :key="key">
|
||||||
<Participant
|
<Participant
|
||||||
v-if="isHost(key)"
|
v-if="isHost(key)"
|
||||||
:talking="activeSpeakerIDs.has(key)"
|
:talking="isTalking(key)"
|
||||||
type="host"
|
type="host"
|
||||||
:data="cachedMastoData[key]"
|
:data="cachedMastoData[key]"
|
||||||
:muted="mutedSpeakerIDs.has(key)"
|
:muted="mutedSpeakerIDs.has(key)"
|
||||||
></Participant>
|
></Participant>
|
||||||
<Participant
|
<Participant
|
||||||
v-if="isCohost(value)"
|
v-if="isCohost(value)"
|
||||||
:talking="activeSpeakerIDs.has(key)"
|
:talking="isTalking(key)"
|
||||||
type="cohost"
|
type="cohost"
|
||||||
:data="cachedMastoData[key]"
|
:data="cachedMastoData[key]"
|
||||||
:muted="mutedSpeakerIDs.has(key)"
|
:muted="mutedSpeakerIDs.has(key)"
|
||||||
></Participant>
|
></Participant>
|
||||||
|
<Participant
|
||||||
|
v-if="isSpeaker(key)"
|
||||||
|
:talking="isTalking(key)"
|
||||||
|
type="speaker"
|
||||||
|
:data="cachedMastoData[key]"
|
||||||
|
:muted="mutedSpeakerIDs.has(key)"
|
||||||
|
>
|
||||||
|
</Participant>
|
||||||
</template>
|
</template>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row>
|
<v-row justify="start">
|
||||||
<template v-for="(value, key) of participants" :key="key">
|
<template v-for="(value, key) of participants" :key="key">
|
||||||
<Participant
|
<Participant
|
||||||
v-if="!isHost(key) && !isCohost(value)"
|
v-if="!isHost(key) && !isCohost(value) && !isSpeaker(key)"
|
||||||
:talking="activeSpeakerIDs.has(key)"
|
|
||||||
:data="cachedMastoData[key]"
|
:data="cachedMastoData[key]"
|
||||||
|
type="listener"
|
||||||
></Participant>
|
></Participant>
|
||||||
</template>
|
</template>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -399,6 +617,23 @@ export default {
|
||||||
@click="roomClient.disconnect()"
|
@click="roomClient.disconnect()"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
|
<v-badge
|
||||||
|
v-if="iamHost || iamCohost"
|
||||||
|
color="info"
|
||||||
|
:model-value="speakRequests.size > 0"
|
||||||
|
:content="speakRequests.size"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:icon="mdiAccountVoice"
|
||||||
|
variant="flat"
|
||||||
|
color="white"
|
||||||
|
@click="
|
||||||
|
showRequestDialog = true;
|
||||||
|
showRequestNotification = false;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</v-btn>
|
||||||
|
</v-badge>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</main>
|
</main>
|
||||||
|
|
11
oauth.go
11
oauth.go
|
@ -56,6 +56,9 @@ func loginHandler(c echo.Context) (err error) {
|
||||||
Scheme: "https",
|
Scheme: "https",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
}
|
}
|
||||||
|
if req.Redirect == "" {
|
||||||
|
req.Redirect = "/"
|
||||||
|
}
|
||||||
|
|
||||||
appConfig, err := getAppConfig(serverURL.String(), req.Redirect)
|
appConfig, err := getAppConfig(serverURL.String(), req.Redirect)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -103,15 +106,15 @@ func oauthHandler(c echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "auth_code_required")
|
return echo.NewHTTPError(http.StatusBadRequest, "auth_code_required")
|
||||||
}
|
}
|
||||||
if req.Redirect == "" {
|
// if req.Redirect == "" {
|
||||||
req.Redirect = "/"
|
// req.Redirect = "/"
|
||||||
}
|
// }
|
||||||
|
|
||||||
data, err := getSessionData(c)
|
data, err := getSessionData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
appConf, err := getAppConfig(data.MastodonConfig.Server, "/")
|
appConf, err := getAppConfig(data.MastodonConfig.Server, req.Redirect)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
76
room.go
76
room.go
|
@ -129,7 +129,24 @@ func joinRoomHandler(c echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := getRoomToken(room, user, room.IsHost(user) || room.IsCoHost(user)) // host and cohost can talk from the beginning
|
canTalk := room.IsHost(user) || room.IsCoHost(user) // host and cohost can talk from the beginning
|
||||||
|
roomMetadata := &RoomMetadata{Room: room}
|
||||||
|
|
||||||
|
// Allows the user to talk if the user is a speaker
|
||||||
|
lkRoom, _ := getRoomInLivekit(c.Request().Context(), room.RoomID) // lkRoom will be nil if it doesn't exist
|
||||||
|
if lkRoom != nil {
|
||||||
|
if existingMetadata, _ := getRoomMetadataFromLivekitRoom(lkRoom); existingMetadata != nil {
|
||||||
|
roomMetadata = existingMetadata
|
||||||
|
for _, speaker := range existingMetadata.Speakers {
|
||||||
|
if speaker.AudonID == user.AudonID {
|
||||||
|
canTalk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := getRoomToken(room, user, canTalk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error(err)
|
c.Logger().Error(err)
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError)
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
|
@ -142,8 +159,8 @@ func joinRoomHandler(c echo.Context) (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create room in LiveKit if it doesn't exist
|
// Create room in LiveKit if it doesn't exist
|
||||||
metadata, _ := json.Marshal(room)
|
metadata, _ := json.Marshal(roomMetadata)
|
||||||
|
if lkRoom == nil {
|
||||||
_, err = lkRoomServiceClient.CreateRoom(c.Request().Context(), &livekit.CreateRoomRequest{
|
_, err = lkRoomServiceClient.CreateRoom(c.Request().Context(), &livekit.CreateRoomRequest{
|
||||||
Name: room.RoomID,
|
Name: room.RoomID,
|
||||||
Metadata: string(metadata),
|
Metadata: string(metadata),
|
||||||
|
@ -152,6 +169,7 @@ func joinRoomHandler(c echo.Context) (err error) {
|
||||||
c.Logger().Error(err)
|
c.Logger().Error(err)
|
||||||
return echo.NewHTTPError(http.StatusConflict)
|
return echo.NewHTTPError(http.StatusConflict)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, resp)
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
@ -194,14 +212,13 @@ func closeRoomHandler(c echo.Context) error {
|
||||||
func updatePermissionHandler(c echo.Context) error {
|
func updatePermissionHandler(c echo.Context) error {
|
||||||
roomID := c.Param("room")
|
roomID := c.Param("room")
|
||||||
|
|
||||||
// look up room in livekit
|
// look up lkRoom in livekit
|
||||||
room, exists := getRoomInLivekit(c.Request().Context(), roomID)
|
lkRoom, exists := getRoomInLivekit(c.Request().Context(), roomID)
|
||||||
if !exists {
|
if !exists {
|
||||||
return ErrRoomNotFound
|
return ErrRoomNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
audonRoom := new(Room)
|
lkRoomMetadata, err := getRoomMetadataFromLivekitRoom(lkRoom)
|
||||||
err := json.Unmarshal([]byte(room.Metadata), audonRoom)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error(err)
|
c.Logger().Error(err)
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError)
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
|
@ -209,25 +226,56 @@ func updatePermissionHandler(c echo.Context) error {
|
||||||
|
|
||||||
iam := c.Get("user").(*AudonUser)
|
iam := c.Get("user").(*AudonUser)
|
||||||
|
|
||||||
if !(audonRoom.IsHost(iam) || audonRoom.IsCoHost(iam)) {
|
if !(lkRoomMetadata.IsHost(iam) || lkRoomMetadata.IsCoHost(iam)) {
|
||||||
return ErrOperationNotPermitted
|
return ErrOperationNotPermitted
|
||||||
}
|
}
|
||||||
|
|
||||||
tgtAudonID := c.Param("user")
|
tgtAudonID := c.Param("user")
|
||||||
|
if !lkRoomMetadata.IsUserInLivekitRoom(c.Request().Context(), tgtAudonID) {
|
||||||
if !audonRoom.IsUserInLivekitRoom(c.Request().Context(), tgtAudonID) {
|
|
||||||
return ErrUserNotFound
|
return ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
tgtUser, err := findUserByID(c.Request().Context(), tgtAudonID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
if lkRoomMetadata.IsHost(tgtUser) || lkRoomMetadata.IsCoHost(tgtUser) {
|
||||||
|
return ErrOperationNotPermitted
|
||||||
|
}
|
||||||
|
|
||||||
permission := new(livekit.ParticipantPermission)
|
newPermission := &livekit.ParticipantPermission{
|
||||||
if err := c.Bind(permission); err != nil {
|
CanPublishData: true,
|
||||||
return ErrInvalidRequestFormat
|
CanSubscribe: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// promote user to a speaker
|
||||||
|
if c.Request().Method == http.MethodPut {
|
||||||
|
newPermission.CanPublish = true
|
||||||
|
for _, speaker := range lkRoomMetadata.Speakers {
|
||||||
|
if speaker.Equal(tgtUser) {
|
||||||
|
return echo.NewHTTPError(http.StatusConflict, "already_speaking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lkRoomMetadata.Speakers = append(lkRoomMetadata.Speakers, tgtUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMetadata, err := json.Marshal(lkRoomMetadata)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger().Error(err)
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
_, err = lkRoomServiceClient.UpdateRoomMetadata(c.Request().Context(), &livekit.UpdateRoomMetadataRequest{
|
||||||
|
Room: roomID,
|
||||||
|
Metadata: string(newMetadata),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Logger().Error(err)
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := lkRoomServiceClient.UpdateParticipant(c.Request().Context(), &livekit.UpdateParticipantRequest{
|
info, err := lkRoomServiceClient.UpdateParticipant(c.Request().Context(), &livekit.UpdateParticipantRequest{
|
||||||
Room: roomID,
|
Room: roomID,
|
||||||
Identity: tgtAudonID,
|
Identity: tgtAudonID,
|
||||||
Permission: permission,
|
Permission: newPermission,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error(err)
|
c.Logger().Error(err)
|
||||||
|
|
28
schema.go
28
schema.go
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/livekit/protocol/livekit"
|
"github.com/livekit/protocol/livekit"
|
||||||
|
@ -25,16 +26,21 @@ type (
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RoomMetadata struct {
|
||||||
|
*Room
|
||||||
|
Speakers []*AudonUser `json:"speakers"`
|
||||||
|
}
|
||||||
|
|
||||||
Room struct {
|
Room struct {
|
||||||
RoomID string `bson:"room_id" json:"room_id" validate:"required,printascii"`
|
RoomID string `bson:"room_id" json:"room_id" validate:"required,printascii"`
|
||||||
Title string `bson:"title" json:"title" validate:"required,max=100,printascii|multibyte"`
|
Title string `bson:"title" json:"title" validate:"required,max=100,printascii|multibyte"`
|
||||||
Description string `bson:"description" json:"description" validate:"max=500,ascii|multibyte"`
|
Description string `bson:"description" json:"description" validate:"max=500,ascii|multibyte"`
|
||||||
Host *AudonUser `bson:"host" json:"host"`
|
Host *AudonUser `bson:"host" json:"host"`
|
||||||
CoHosts []*AudonUser `bson:"cohost" json:"cohosts,omitempty"`
|
CoHosts []*AudonUser `bson:"cohost" json:"cohosts"`
|
||||||
FollowingOnly bool `bson:"following_only" json:"following_only"`
|
FollowingOnly bool `bson:"following_only" json:"following_only"`
|
||||||
FollowerOnly bool `bson:"follower_only" json:"follower_only"`
|
FollowerOnly bool `bson:"follower_only" json:"follower_only"`
|
||||||
MutualOnly bool `bson:"mutual_only" json:"mutual_only"`
|
MutualOnly bool `bson:"mutual_only" json:"mutual_only"`
|
||||||
Kicked []*AudonUser `bson:"kicked" json:"kicked,omitempty"`
|
Kicked []*AudonUser `bson:"kicked" json:"kicked"`
|
||||||
ScheduledAt time.Time `bson:"scheduled_at" json:"scheduled_at"`
|
ScheduledAt time.Time `bson:"scheduled_at" json:"scheduled_at"`
|
||||||
EndedAt time.Time `bson:"ended_at" json:"ended_at"`
|
EndedAt time.Time `bson:"ended_at" json:"ended_at"`
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
@ -78,7 +84,25 @@ func (r *Room) IsHost(u *AudonUser) bool {
|
||||||
return r != nil && r.Host.Equal(u)
|
return r != nil && r.Host.Equal(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getRoomMetadataFromLivekitRoom(lkRoom *livekit.Room) (*RoomMetadata, error) {
|
||||||
|
metadata := new(RoomMetadata)
|
||||||
|
if err := json.Unmarshal([]byte(lkRoom.GetMetadata()), metadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ExistsInLivekit(ctx context.Context) bool {
|
||||||
|
lkRooms, _ := lkRoomServiceClient.ListRooms(ctx, &livekit.ListRoomsRequest{Names: []string{r.RoomID}})
|
||||||
|
|
||||||
|
return len(lkRooms.GetRooms()) > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Room) IsUserInLivekitRoom(ctx context.Context, userID string) bool {
|
func (r *Room) IsUserInLivekitRoom(ctx context.Context, userID string) bool {
|
||||||
|
if r == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
participantsInfo, _ := lkRoomServiceClient.ListParticipants(ctx, &livekit.ListParticipantsRequest{Room: r.RoomID})
|
participantsInfo, _ := lkRoomServiceClient.ListParticipants(ctx, &livekit.ListParticipantsRequest{Room: r.RoomID})
|
||||||
participants := participantsInfo.GetParticipants()
|
participants := participantsInfo.GetParticipants()
|
||||||
|
|
||||||
|
|
27
server.go
27
server.go
|
@ -132,12 +132,12 @@ func main() {
|
||||||
api.POST("/room", createRoomHandler)
|
api.POST("/room", createRoomHandler)
|
||||||
api.GET("/room/:id", joinRoomHandler)
|
api.GET("/room/:id", joinRoomHandler)
|
||||||
api.DELETE("/room/:id", closeRoomHandler)
|
api.DELETE("/room/:id", closeRoomHandler)
|
||||||
api.PATCH("/room/:room/:user", updatePermissionHandler)
|
api.PUT("/room/:room/:user", updatePermissionHandler)
|
||||||
|
|
||||||
e.Static("/assets", "audon-fe/dist/assets")
|
e.Static("/assets", "audon-fe/dist/assets")
|
||||||
e.File("/*", "audon-fe/dist/index.html")
|
e.File("/*", "audon-fe/dist/index.html")
|
||||||
|
|
||||||
e.Logger.Debug(e.Start(":1323"))
|
e.Logger.Debug(e.Start(":8100"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||||
|
@ -152,16 +152,19 @@ func (cv *CustomValidator) Validate(i interface{}) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAppConfig(server string, redirPath string) (*mastodon.AppConfig, error) {
|
func getAppConfig(server string, redirPath string) (*mastodon.AppConfig, error) {
|
||||||
if mastAppConfigBase != nil {
|
// if mastAppConfigBase != nil {
|
||||||
return &mastodon.AppConfig{
|
// return &mastodon.AppConfig{
|
||||||
Server: server,
|
// Server: server,
|
||||||
ClientName: mastAppConfigBase.ClientName,
|
// ClientName: mastAppConfigBase.ClientName,
|
||||||
Scopes: mastAppConfigBase.Scopes,
|
// Scopes: mastAppConfigBase.Scopes,
|
||||||
Website: mastAppConfigBase.Website,
|
// Website: mastAppConfigBase.Website,
|
||||||
RedirectURIs: mastAppConfigBase.RedirectURIs,
|
// RedirectURIs: mastAppConfigBase.RedirectURIs,
|
||||||
}, nil
|
// }, nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
if redirPath == "" {
|
||||||
|
redirPath = "/"
|
||||||
|
}
|
||||||
redirectURI := "urn:ietf:wg:oauth:2.0:oob"
|
redirectURI := "urn:ietf:wg:oauth:2.0:oob"
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Host: mainConfig.LocalDomain,
|
Host: mainConfig.LocalDomain,
|
||||||
|
@ -181,7 +184,7 @@ func getAppConfig(server string, redirPath string) (*mastodon.AppConfig, error)
|
||||||
RedirectURIs: redirectURI,
|
RedirectURIs: redirectURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
mastAppConfigBase = conf
|
// mastAppConfigBase = conf
|
||||||
|
|
||||||
return &mastodon.AppConfig{
|
return &mastodon.AppConfig{
|
||||||
Server: server,
|
Server: server,
|
||||||
|
|
Ładowanie…
Reference in New Issue