kopia lustrzana https://codeberg.org/nmkj/audon
add features of mute, disconnect, and close
rodzic
430ba06184
commit
52c7587ce4
|
@ -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>
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -8,7 +8,11 @@ export const useMastodonStore = defineStore("mastodon", {
|
|||
state() {
|
||||
return {
|
||||
authorized: false,
|
||||
oauth: null,
|
||||
oauth: {
|
||||
url: "",
|
||||
token: "",
|
||||
audon_id: "",
|
||||
},
|
||||
client: null,
|
||||
userinfo: null,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
27
oauth.go
27
oauth.go
|
@ -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
12
room.go
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue