add features of mute, disconnect, and close

pull/6/head
Namekuji 2022-12-08 19:21:17 -05:00
rodzic 430ba06184
commit 52c7587ce4
9 zmienionych plików z 283 dodań i 34 usunięć

Wyświetl plik

@ -1,9 +1,17 @@
<script>
import { mdiMicrophone, mdiMicrophoneOff } from "@mdi/js";
export default {
props: {
talking: Boolean,
type: String,
data: Object,
muted: Boolean,
},
data () {
return {
mdiMicrophone,
mdiMicrophoneOff
}
},
computed: {
isHostOrCohost() {
@ -44,10 +52,17 @@ export default {
<v-img :src="data?.avatar"></v-img>
</v-avatar>
</v-badge>
<v-avatar v-else :class="{ rounded: true, talk: talking, 'mt-2': true }" size="70">
<v-avatar
v-else
:class="{ rounded: true, talk: talking, 'mt-2': true }"
size="70"
>
<v-img :src="data?.avatar"></v-img>
</v-avatar>
<h4 :class="isHostOrCohost ? 'mt-1' : 'mt-2'">{{ data?.displayName }}</h4>
<h4 :class="isHostOrCohost ? 'mt-1' : 'mt-2'">
<v-icon v-if="isHostOrCohost" :icon="muted ? mdiMicrophoneOff : mdiMicrophone"></v-icon>
<a :href="data?.url" target="_blank">{{ data?.displayName }}</a>
</h4>
</v-col>
</template>
@ -55,4 +70,8 @@ export default {
.talk {
outline: 3px solid cornflowerblue;
}
a {
color: inherit;
text-decoration: inherit;
}
</style>

Wyświetl plik

@ -39,20 +39,22 @@ axios.interceptors.response.use(undefined, (error) => {
});
router.beforeEach(async (to) => {
const donStore = useMastodonStore();
if (!to.meta.noauth && !donStore.authorized) {
if ((!to.meta.noauth || to.name === "login") && !donStore.authorized) {
try {
await donStore.fetchToken();
if (!donStore.client) await donStore.fetchToken();
} catch (error) {
if (error.response?.status === 401) {
donStore.$reset();
}
}
}
})
});
router.afterEach((to) => {
const donStore = useMastodonStore();
if (!to.meta.noauth && !donStore.authorized) {
router.push({ name: "login" }); // need to push in afterEach to get nonempty lastPath in LoginView.vue
} else if (to.name === "login" && donStore.authorized) {
router.replace({ name: "home" });
}
});

Wyświetl plik

@ -8,7 +8,11 @@ export const useMastodonStore = defineStore("mastodon", {
state() {
return {
authorized: false,
oauth: null,
oauth: {
url: "",
token: "",
audon_id: "",
},
client: null,
userinfo: null,
};

Wyświetl plik

@ -1,6 +1,7 @@
<script>
import { RouterLink } from "vue-router";
import { useMastodonStore } from "../stores/mastodon";
import { mdiLogout } from "@mdi/js";
import axios from "axios";
export default {
setup() {
@ -10,15 +11,44 @@ export default {
},
data() {
return {
query: ""
}
}
mdiLogout,
query: "",
};
},
methods: {
async onLogout() {
if (!confirm("Audon からログアウトしますか?")) return;
try {
const resp = await axios.post("/app/logout");
if (resp.status === 200) {
this.donStore.$reset();
this.$router.push({ name: "login" });
}
} catch (error) {
console.log(error);
} finally {
this.donStore.$reset();
this.$router.push({ name: "login" });
}
},
},
};
</script>
<template>
<main>
<div class="text-center my-10" >
<div class="text-right">
<v-btn
:append-icon="mdiLogout"
variant="outlined"
color="red"
@click="onLogout"
>
ログアウト
</v-btn>
</div>
<div class="text-center my-10">
<v-avatar class="rounded" size="100">
<v-img
:src="donStore.userinfo?.avatar"
@ -38,7 +68,7 @@ export default {
<v-text-field v-mode="query"></v-text-field>
</v-col> -->
<v-col cols="12">
<v-btn block :to="{name: 'create'}" color="indigo">部屋を作成</v-btn>
<v-btn block :to="{ name: 'create' }" color="indigo">部屋を作成</v-btn>
</v-col>
</v-row>
</main>

Wyświetl plik

@ -9,8 +9,15 @@ import {
mdiMicrophoneOff,
mdiPhoneRemove,
mdiMicrophoneQuestion,
mdiDoorClosed,
mdiVolumeOff
} from "@mdi/js";
import { Room, RoomEvent, Track } from "livekit-client";
import {
Room,
RoomEvent,
Track,
DisconnectReason,
} from "livekit-client";
import { login } from "masto";
export default {
@ -28,10 +35,12 @@ export default {
mdiMicrophoneOff,
mdiPhoneRemove,
mdiMicrophoneQuestion,
mdiDoorClosed,
mdiVolumeOff,
roomID: this.$route.params.id,
loading: false,
mainHeight: 600,
roomClient: null,
roomClient: new Room(),
roomInfo: {
title: "",
description: "",
@ -42,6 +51,9 @@ export default {
participants: {},
cachedMastoData: {},
activeSpeakerIDs: new Set(),
mutedSpeakerIDs: new Set(),
micGranted: false,
autoplayDisabled: false,
};
},
created() {
@ -59,8 +71,40 @@ export default {
mounted() {
this.onResize();
},
computed: {
iamMuted() {
const myAudonID = this.donStore.oauth.audon_id;
return (
(this.iamHost || this.iamCohost) &&
this.micGranted &&
this.mutedSpeakerIDs.has(myAudonID)
);
},
iamHost() {
const myAudonID = this.donStore.oauth.audon_id;
if (!myAudonID) return false;
return this.isHost(myAudonID);
},
iamCohost() {
const myInfo = this.donStore.userinfo;
if (!myInfo) return false;
return this.isCohost({remote_id: myInfo.id , remote_url: myInfo.url});
},
micStatusIcon() {
if (!this.micGranted) {
return mdiMicrophoneQuestion;
}
if (this.iamMuted) {
return mdiMicrophoneOff;
}
return mdiMicrophone;
},
},
methods: {
async joinRoom() {
if (!this.donStore.authorized) return;
this.loading = true;
try {
const resp = await axios.get(`/api/room/${this.roomID}`);
@ -86,6 +130,10 @@ export default {
track.detach();
}
)
.on(RoomEvent.LocalTrackPublished, (publication, participant) => {
self.micGranted = true;
self.mutedSpeakerIDs.delete(participant.identity);
})
.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
publication.track?.detach();
})
@ -98,17 +146,42 @@ export default {
self.fetchMastoData(participant.identity, metadata);
}
})
.on(RoomEvent.TrackMuted, (publication, participant) => {
self.mutedSpeakerIDs.add(participant.identity);
})
.on(RoomEvent.TrackUnmuted, (publication, participant) => {
self.mutedSpeakerIDs.delete(participant.identity);
})
.on(RoomEvent.ParticipantDisconnected, (participant) => {
self.participants = omit(self.participants, participant.identity);
self.mutedSpeakerIDs.delete(participant.identity);
})
.on(RoomEvent.AudioPlaybackStatusChanged, () => {
if (!room.canPlaybackAudio) {
// FIXME: popup a dialog to ask user to allow audio playback
console.log("needs audio playback permission");
// alert("autoplay not permitted");
self.autoplayDisabled = true
}
})
.on(RoomEvent.Disconnected, (reason) => {
console.log("disconnected: ", reason);
// TODO: change this from alert to a vuetify thing
let message = "";
switch (reason) {
case DisconnectReason.ROOM_DELETED:
message = "ホストにより部屋が閉じられました。";
break;
case DisconnectReason.PARTICIPANT_REMOVED:
message = "部屋から退去しました";
break;
case DisconnectReason.CLIENT_INITIATED:
break;
default:
message = "Disconnected due to unknown reasons";
}
if (message !== "") {
alert(message);
}
self.$router.push({ name: "home" });
});
await room.connect(resp.data.url, resp.data.token);
this.roomClient = room;
@ -126,11 +199,25 @@ export default {
this.fetchMastoData(key, value);
}
}
if (this.iamHost || this.iamCohost) {
try {
await room.localParticipant.setMicrophoneEnabled(true);
} catch {
alert("ブラウザが録音を許可していません");
}
}
} catch (error) {
if (error.response?.status === 404) {
pushNotFound(this.$route);
} else if (error.response?.status === 406) {
alert(
"他のデバイスで入室済みです。切断された場合はしばらく待ってからやり直してください。"
);
this.$router.push({ name: "home" });
} else {
console.log(error);
// FIXME: error handling
alert(error);
this.$router.push({ name: "home" });
}
} finally {
this.loading = false;
@ -141,6 +228,9 @@ export default {
const height = mainArea.clientHeight;
this.mainHeight = height > 700 ? 700 : window.innerHeight - 70;
},
isHost(identity) {
return identity === this.roomInfo.host?.audon_id;
},
isCohost(value) {
return (
value &&
@ -154,7 +244,16 @@ export default {
const metadata = participant.metadata
? JSON.parse(participant.metadata)
: null;
this.participants[participant.identity] = metadata;
if (metadata) {
this.participants[participant.identity] = metadata;
const track = participant.getTrack(Track.Source.Microphone);
if (
(this.isHost(participant.identity) || this.isCohost(metadata)) &&
track?.isMuted
) {
this.mutedSpeakerIDs.add(participant.identity);
}
}
return metadata;
},
async fetchMastoData(identity, { remote_id, remote_url }) {
@ -172,15 +271,78 @@ export default {
console.log(error);
}
},
async onToggleMute() {
const myTrack = this.roomClient.localParticipant.getTrack(
Track.Source.Microphone
);
if (this.iamHost || this.iamCohost) {
try {
if (!this.micGranted) {
await this.roomClient.localParticipant.setMicrophoneEnabled(true);
} else if (myTrack) {
await this.roomClient.localParticipant.setMicrophoneEnabled(
myTrack.isMuted
);
}
} catch {
alert("ブラウザが録音を許可していません");
}
} else {
alert("リクエストはアップデートで実装予定です!");
}
},
async onRoomClose() {
// TODO: change this from confirm to a vuetify thing
if (confirm("この部屋を閉じますか?")) {
try {
await axios.delete(`/api/room/${this.roomID}`);
} catch (error) {
alert(error);
}
}
},
async onStartListening() {
try {
await this.roomClient.startAudio();
this.autoplayDisabled = false;
} catch {
alert("接続できませんでした。退室します。");
await this.roomClient.disconnect();
}
}
},
};
</script>
<template>
<v-dialog v-model="autoplayDisabled" max-width="500" persistent>
<v-alert color="indigo">
<div class="mb-5">ブラウザの設定により無音になっています続行するには視聴を始めるボタンを押してください</div>
<div class="text-center mb-3">
<v-btn color="gray" @click="onStartListening"></v-btn>
</div>
<div class="text-center">
<v-btn variant="text" @click="roomClient.disconnect()">退</v-btn>
</div>
</v-alert>
</v-dialog>
<div class="d-none" ref="audioDOM"></div>
<main class="fill-height" v-resize="onResize">
<v-card :height="mainHeight" :loading="loading" class="d-flex flex-column">
<v-card-title>{{ roomInfo.title }}</v-card-title>
<v-card-title class="d-flex justify-space-between">
<div>{{ roomInfo.title }}</div>
<div>
<v-btn
v-if="iamHost"
:append-icon="mdiDoorClosed"
variant="outlined"
color="red"
@click="onRoomClose"
>
閉室
</v-btn>
</div>
</v-card-title>
<div
class="overflow-auto flex-shrink-0 pb-2"
v-if="roomInfo.description"
@ -195,23 +357,25 @@ export default {
<v-row justify="start">
<template v-for="(value, key) of participants" :key="key">
<Participant
v-if="key === roomInfo.host?.audon_id"
v-if="isHost(key)"
:talking="activeSpeakerIDs.has(key)"
type="host"
:data="cachedMastoData[key]"
:muted="mutedSpeakerIDs.has(key)"
></Participant>
<Participant
v-if="isCohost(value)"
:talking="activeSpeakerIDs.has(key)"
type="cohost"
:data="cachedMastoData[key]"
:muted="mutedSpeakerIDs.has(key)"
></Participant>
</template>
</v-row>
<v-row>
<template v-for="(value, key) of participants" :key="key">
<Participant
v-if="key !== roomInfo.host?.audon_id && !isCohost(value)"
v-if="!isHost(key) && !isCohost(value)"
:talking="activeSpeakerIDs.has(key)"
:data="cachedMastoData[key]"
></Participant>
@ -221,11 +385,17 @@ export default {
<v-divider></v-divider>
<v-card-actions class="justify-center" style="gap: 50px">
<v-btn
:icon="mdiMicrophoneQuestion"
:icon="micStatusIcon"
color="white"
variant="flat"
@click="onToggleMute"
></v-btn>
<v-btn
:icon="mdiPhoneRemove"
color="red"
@click="roomClient.disconnect()"
variant="flat"
></v-btn>
<v-btn :icon="mdiPhoneRemove" color="red" variant="flat"></v-btn>
</v-card-actions>
</v-card>
</main>

Wyświetl plik

@ -173,11 +173,34 @@ func getOAuthTokenHandler(c echo.Context) (err error) {
}
return c.JSON(http.StatusOK, &TokenResponse{
Url: data.MastodonConfig.Server,
Token: data.MastodonConfig.AccessToken,
Url: data.MastodonConfig.Server,
Token: data.MastodonConfig.AccessToken,
AudonID: data.AudonID,
})
}
func logoutHandler(c echo.Context) (err error) {
data, err := getSessionData(c)
if err == nil && data.AudonID != "" {
mastoURL, err := url.Parse(data.MastodonConfig.Server)
if err != nil {
return ErrInvalidRequestFormat
}
mastoURL = mastoURL.JoinPath("oauth", "revoke")
formValues := url.Values{}
formValues.Add("client_id", data.MastodonConfig.ClientID)
formValues.Add("client_secret", data.MastodonConfig.ClientSecret)
formValues.Add("token", data.MastodonConfig.AccessToken)
resp, err := http.PostForm(mastoURL.String(), formValues)
if err == nil && resp.StatusCode == http.StatusOK {
return c.NoContent(http.StatusOK)
}
return echo.NewHTTPError(http.StatusBadRequest)
}
return echo.NewHTTPError(http.StatusUnauthorized, "login_required")
}
func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
data, err := getSessionData(c)

12
room.go
Wyświetl plik

@ -15,13 +15,6 @@ import (
"go.mongodb.org/mongo-driver/mongo"
)
type (
TokenResponse struct {
Url string `json:"url"`
Token string `json:"token"`
}
)
// handler for POST to /api/room
func createRoomHandler(c echo.Context) error {
room := new(Room)
@ -143,8 +136,9 @@ func joinRoomHandler(c echo.Context) (err error) {
}
resp := &TokenResponse{
Url: mainConfig.Livekit.URL.String(),
Token: token,
Url: mainConfig.Livekit.URL.String(),
Token: token,
AudonID: user.AudonID,
}
// Create room in LiveKit if it doesn't exist

Wyświetl plik

@ -39,6 +39,12 @@ type (
EndedAt time.Time `bson:"ended_at" json:"ended_at"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
TokenResponse struct {
Url string `json:"url"`
Token string `json:"token"`
AudonID string `json:"audon_id"`
}
)
const (

Wyświetl plik

@ -123,6 +123,7 @@ func main() {
e.POST("/app/login", loginHandler)
e.GET("/app/oauth", oauthHandler)
e.GET("/app/verify", verifyHandler)
e.POST("/app/logout", logoutHandler)
e.POST("/app/webhook", livekitWebhookHandler)