diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9829cc8d..ac912c4edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,12 @@ - Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加 - Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: ユーザーのRawデータを表示するページが復活 +- Enhance: リアクション選択時に音を鳴らせるように - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Fix: コードエディタが正しく表示されない問題を修正 - Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 +- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正 ### Server - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 2ea9d116e1..9f1a8c7761 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1943,6 +1943,7 @@ export interface Locale { "notification": string; "antenna": string; "channel": string; + "reaction": string; }; "_ago": { "future": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d78c949c7b..c2482e5a74 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1848,6 +1848,7 @@ _sfx: notification: "通知" antenna: "アンテナ受信" channel: "チャンネル通知" + reaction: "リアクション選択時" _ago: future: "未来" diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index c456874309..bc6cbe242f 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -64,16 +64,16 @@ export default class extends Endpoint { // eslint- } } - if (noteIds.length === 0) { - return []; - } - noteIds.sort((a, b) => a > b ? -1 : 1); if (ps.untilId) { noteIds = noteIds.filter(id => id < ps.untilId!); } noteIds = noteIds.slice(0, ps.limit); + if (noteIds.length === 0) { + return []; + } + const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') diff --git a/packages/frontend/assets/sounds/syuilo/bubble1.mp3 b/packages/frontend/assets/sounds/syuilo/bubble1.mp3 new file mode 100644 index 0000000000..05b8ef8b10 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/bubble1.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/bubble2.mp3 b/packages/frontend/assets/sounds/syuilo/bubble2.mp3 new file mode 100644 index 0000000000..8b4f8df6e9 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/bubble2.mp3 differ diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 6349df2e30..d047495dc9 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -163,6 +163,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -336,6 +337,8 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { + sound.play('reaction'); + if (props.mock) { return; } @@ -354,6 +357,8 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + if (props.mock) { emit('reaction', reaction); return; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index d1bc3f676f..d8089ac36f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -210,6 +210,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -369,6 +370,8 @@ function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { + sound.play('reaction'); + os.api('notes/reactions/create', { noteId: appearNote.id, reaction: '❤️', @@ -383,6 +386,8 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + os.api('notes/reactions/create', { noteId: appearNote.id, reaction: reaction, diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 9a107c3674..65a5c2374e 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -28,6 +28,7 @@ import MkReactionEffect from '@/components/MkReactionEffect.vue'; import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ reaction: string; @@ -59,6 +60,10 @@ async function toggleReaction() { }); if (confirm.canceled) return; + if (oldReaction !== props.reaction) { + sound.play('reaction'); + } + if (mock) { emit('reactionToggled', props.reaction, (props.count - 1)); return; @@ -75,6 +80,8 @@ async function toggleReaction() { } }); } else { + sound.play('reaction'); + if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); return; diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index cd1707a594..244bb1e0e2 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -38,7 +38,7 @@ import { defaultStore } from '@/store.js'; const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume')); -const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel'] as const; +const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel', 'reaction'] as const; const sounds = ref>>({ note: defaultStore.reactiveState.sound_note, @@ -46,6 +46,7 @@ const sounds = ref>>({ notification: defaultStore.reactiveState.sound_notification, antenna: defaultStore.reactiveState.sound_antenna, channel: defaultStore.reactiveState.sound_channel, + reaction: defaultStore.reactiveState.sound_reaction, }); async function updated(type: keyof typeof sounds.value, sound) { diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 2b604bd98a..d28d629227 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -5,8 +5,9 @@ import { defaultStore } from '@/store.js'; -const ctx = new AudioContext(); +let ctx: AudioContext; const cache = new Map(); +let canPlay = true; export const soundsTypes = [ null, @@ -38,6 +39,8 @@ export const soundsTypes = [ 'syuilo/waon', 'syuilo/popo', 'syuilo/triple', + 'syuilo/bubble1', + 'syuilo/bubble2', 'syuilo/poi1', 'syuilo/poi2', 'syuilo/pirori', @@ -62,6 +65,9 @@ export const soundsTypes = [ ] as const; export async function loadAudio(file: string, useCache = true) { + if (ctx == null) { + ctx = new AudioContext(); + } if (useCache && cache.has(file)) { return cache.get(file)!; } @@ -77,11 +83,18 @@ export async function loadAudio(file: string, useCache = true) { return audioBuffer; } -export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') { +export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification' | 'reaction') { const sound = defaultStore.state[`sound_${type}`]; if (_DEV_) console.log('play', type, sound); - if (sound.type == null) return; - playFile(sound.type, sound.volume); + if (sound.type == null || !canPlay) return; + + canPlay = false; + playFile(sound.type, sound.volume).then(() => { + // ごく短時間に音が重複しないように + setTimeout(() => { + canPlay = true; + }, 25); + }); } export async function playFile(file: string, volume: number) { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 12660e9e8d..f2ed4e7c0b 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -411,6 +411,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: { type: 'syuilo/square-pico', volume: 1 }, }, + sound_reaction: { + where: 'device', + default: { type: 'syuilo/bubble2', volume: 1 }, + }, })); // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index fa82997570..8c990e8e49 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -58,6 +58,7 @@ import { useStream } from '@/stream.js'; import number from '@/filters/number.js'; import * as sound from '@/scripts/sound.js'; import { deepClone } from '@/scripts/clone.js'; +import { defaultStore } from '@/store.js'; const name = 'jobQueue'; @@ -102,7 +103,9 @@ const prev = reactive({} as typeof current); let jammedAudioBuffer: AudioBuffer | null = $ref(null); let jammedSoundNodePlaying: boolean = $ref(false); -sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf); +if (defaultStore.state.sound_masterVolume) { + sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf); +} for (const domain of ['inbox', 'deliver']) { prev[domain] = deepClone(current[domain]);