mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-19 16:09:02 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e216748a21 | |||
| ff7f2aeb7a | |||
| 0c0733d11e | |||
| 27b0372783 | |||
| 8da1b52c9b | |||
| 638307ff3e | |||
| 7aa0089a5a | |||
| 3129d1ac4f | |||
| 819a66d610 | |||
| dacda056fb | |||
| ab26d2b7b2 | |||
| 12f0fb7738 | |||
| abd2deae79 | |||
| 9d24906786 |
@@ -6,6 +6,8 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/api-misskey-js.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/api-misskey-js.yml
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
check-spdx-license-id:
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
dockle:
|
||||
|
||||
@@ -21,6 +21,8 @@ on:
|
||||
- .github/workflows/lint.yml
|
||||
- package.json
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/frontend/**
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- locales/**
|
||||
- .github/workflows/locale.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/i18n/**
|
||||
- locales/**
|
||||
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
- pnpm-lock.yaml
|
||||
- .github/workflows/packages-private.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages-private/**
|
||||
- packages/shared/eslint.config.js
|
||||
|
||||
@@ -12,6 +12,8 @@ on:
|
||||
- .github/workflows/test-backend.yml
|
||||
- .github/misskey/test.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/backend/**
|
||||
# for permissions
|
||||
|
||||
@@ -10,6 +10,8 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
|
||||
@@ -14,6 +14,8 @@ on:
|
||||
- .github/workflows/test-frontend.yml
|
||||
- .github/misskey/test.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
# for permissions
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- packages/backend/**
|
||||
- .github/workflows/validate-api-json.yml
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- master
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- .github/workflows/validate-api-json.yml
|
||||
|
||||
+6
-4
@@ -1,14 +1,19 @@
|
||||
## 2026.8.0
|
||||
|
||||
### General
|
||||
-
|
||||
- Enhance: ノート翻訳時、CWも翻訳対象に含めるように
|
||||
|
||||
### Client
|
||||
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
|
||||
- Enhance: メディアリスト上でぼかしを解除したメディアを画像ビューワーで表示した際にぼかしを解除した状態で表示するように
|
||||
- Enhance: 音声プレイヤーを画像・動画ビューワーに統合
|
||||
- Enhance: パフォーマンスの向上
|
||||
- Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正
|
||||
- Fix: モバイルでの利用時に一部のテキストが選択できない問題を修正
|
||||
|
||||
### Server
|
||||
- Fix: 既にミュートしているスレッドに対して再度スレッドミュートを作成しようとするとサーバーエラーになる問題を修正
|
||||
- Fix: 古いバージョンのMisskeyで作成されたアカウントでノートの連合が行えない場合があるのを修正
|
||||
|
||||
|
||||
## 2026.7.0
|
||||
@@ -29,9 +34,6 @@
|
||||
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
|
||||
|
||||
### General
|
||||
- Feat: 自分に表示する公開ロールとロールバッジをユーザー側で選べるように
|
||||
- 必ず表示する必要があるロールを作成することもできます(既存のロールについてはすべて表示必須設定となります。必要に応じて設定を変更してください)
|
||||
- 今後新規作成するロールのデフォルト値は表示必須設定なし(ユーザーが表示・非表示を選べる)となります。
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "Afegeix al final"
|
||||
prepend: "Afegeix al principi"
|
||||
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
|
||||
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
|
||||
pixelatedZoom: "Mode de zoom d'art pixelat"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Títol de l'arxiu"
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "Añadir al final"
|
||||
prepend: "Añadir al principio"
|
||||
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
|
||||
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
|
||||
pixelatedZoom: "Modo de ampliación para pixel art"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Título del archivo"
|
||||
|
||||
+32
-3
@@ -174,7 +174,7 @@ emojiUrl: "پیوند شکلک"
|
||||
addEmoji: "افزودن شکلک"
|
||||
settingGuide: "تنظیمات پیشنهادی"
|
||||
cacheRemoteFiles: "کش کردن پرونده های خارجی"
|
||||
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش تصاویر را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
|
||||
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش پرونده ها را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
|
||||
youCanCleanRemoteFilesCache: "شما می توانید حافظه پنهان (کش) را با کلیک بر دکمه 🗑️ در پایین صفحه مدیریت پرونده ها حذف کنید."
|
||||
cacheRemoteSensitiveFiles: "کش کردن پرونده های حساس سرور های خارجی"
|
||||
cacheRemoteSensitiveFilesDescription: "با غیر فعال کردن این قابلیت پرونده هایی که علامت حساس خورده اند در سرور های خارجی به جای اینکه در حافظه پنهان ذخیره شوند، مستقیما به ان پیوند داده می شوند."
|
||||
@@ -200,23 +200,47 @@ removeWallpaper: "حذف تصویر پسزمینه"
|
||||
searchWith: "جستجو: {q}"
|
||||
youHaveNoLists: "شما فهرستی ندارید"
|
||||
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
|
||||
proxyAccount: "حساب وکیل"
|
||||
proxyAccount: "حساب نماینده (پروکسی)"
|
||||
host: "میزبان"
|
||||
selectSelf: "انتخاب خودت"
|
||||
selectUser: "انتخاب کاربر"
|
||||
recipient: "گیرنده"
|
||||
annotation: "حاشیه نویسی"
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
instances: "سرور"
|
||||
registeredAt: "ثبت شده در"
|
||||
latestRequestReceivedAt: "آخرین درخواست دریافت شده"
|
||||
latestStatus: "جدیدترین وضعیت"
|
||||
storageUsage: "حافظه مصرفی"
|
||||
charts: "نمودارها"
|
||||
perHour: "در ساعت"
|
||||
perHour: "هر ۱ ساعت"
|
||||
perDay: "هر ۱ روز"
|
||||
stopActivityDelivery: "جلوگیری از ارائه فعالیت ها"
|
||||
blockThisInstance: "این سرور را مسدود کن"
|
||||
silenceThisInstance: "این سرور را بیصدا کن"
|
||||
mediaSilenceThisInstance: "محتوای چندرسانه ای این سرور را بیصدا کن"
|
||||
operations: "عملیات ها"
|
||||
software: "نرمافزار"
|
||||
softwareName: "نام نرمافزار"
|
||||
version: "نگارش"
|
||||
metadata: "متادیتا"
|
||||
withNFiles: "{n} پرونده"
|
||||
monitor: "رصدگر"
|
||||
jobQueue: "صفکار"
|
||||
cpuAndMemory: "پردازنده و حافظه رم"
|
||||
network: "شبکه"
|
||||
disk: "حافظه ذخیره"
|
||||
instanceInfo: "اطلاعات سرور"
|
||||
statistics: "آمار"
|
||||
clearQueue: "پاکسازی صف"
|
||||
clearQueueConfirmTitle: "آیا می خواهید این صف را پاک کنید؟"
|
||||
clearQueueConfirmText: "هر یادداشت تحویل داده نشده در شبکه اجتماعی غیرمتمرکز منتشر نخواهد شد. معمولا این عملیات لازم نیست."
|
||||
clearCachedFiles: "پاک کردن حافظه پنهان"
|
||||
clearCachedFilesConfirm: "آیا می خواهید که تمام پرونده هایی که از سرور های در حافظه پنهان دریافت کردید حذف شود."
|
||||
blockedInstances: "سرور های مسدود شده"
|
||||
blockedInstancesDescription: "نام سرور هایی که می خواهید مسدود کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. سرور های فهرست شده دیگه قابلیت ارتباط گیری با این سرور را ندارند."
|
||||
silencedInstances: "سرور های بیصدا شده"
|
||||
silencedInstancesDescription: "نام سرور هایی که میخواهید بیصدا کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. تمام حساب ها از سرور های لیست شده \"بیصدا\" در نظر گرفته می شود و همهی دنبال کردن ها به کاربر های این سرور به درخواست دنبال کردن تبدیل میشود. این قابلیت بر روی سرور های مسدود شده تاثیری نخواهد داشت."
|
||||
noCustomEmojis: "هیچ شکلکی نیست"
|
||||
remove: "حذف"
|
||||
pinnedNotes: "یادداشت سنجاق شده"
|
||||
@@ -227,6 +251,7 @@ smtpHost: "میزبان"
|
||||
smtpUser: "نام کاربری"
|
||||
smtpPass: "گذرواژه"
|
||||
theKeywordWhenSearchingForCustomEmoji: "این کلمه کلیدی است که هنگام جستجو در میان شکلک های سفارشی به کار گرفته می شود."
|
||||
clearCache: "پاک کردن حافظه پنهان"
|
||||
user: "کاربران"
|
||||
searchByGoogle: "جستجو"
|
||||
file: "پرونده ها"
|
||||
@@ -256,6 +281,8 @@ _widgets:
|
||||
instanceInfo: "اطلاعات سرور"
|
||||
notifications: "اعلان ها"
|
||||
timeline: "نوار زمانی"
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
jobQueue: "صفکار"
|
||||
_userList:
|
||||
chooseList: "انتخاب یک فهرست"
|
||||
_cw:
|
||||
@@ -270,6 +297,8 @@ _exportOrImport:
|
||||
muteList: "سکوت"
|
||||
blockingList: "مسدود کردن"
|
||||
userLists: "فهرست ها"
|
||||
_charts:
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
_notification:
|
||||
youWereFollowed: "شما دنبال شده اید"
|
||||
_types:
|
||||
|
||||
@@ -1040,6 +1040,7 @@ thisPostMayBeAnnoyingHome: "Publier vers le fil principal"
|
||||
thisPostMayBeAnnoyingCancel: "Annuler"
|
||||
thisPostMayBeAnnoyingIgnore: "Publier quand-même"
|
||||
collapseRenotes: "Réduire les renotes déjà vues"
|
||||
collapseRenotesDescription: "Réduire les notes que vous avez renoté ou déjà réagi."
|
||||
internalServerError: "Erreur interne du serveur"
|
||||
internalServerErrorDescription: "Une erreur inattendue s'est produite sur le serveur."
|
||||
copyErrorInfo: "Copier les détails de l’erreur"
|
||||
@@ -1283,6 +1284,7 @@ postForm: "Formulaire de publication"
|
||||
information: "Informations"
|
||||
inMinutes: "min"
|
||||
inDays: "j"
|
||||
createUserSpecifiedNote: "Créer une note directe"
|
||||
widgets: "Widgets"
|
||||
presets: "Préréglage"
|
||||
previewingThemeRestore: "Restaurer"
|
||||
@@ -1758,6 +1760,8 @@ _aboutMisskey:
|
||||
patrons: "Contributeurs"
|
||||
projectMembers: "Membres du projet"
|
||||
_displayOfSensitiveMedia:
|
||||
respect: "Cacher les médias marqués comme sensibles"
|
||||
ignore: "Afficher les médias marqués comme sensibles"
|
||||
force: "Masquer tous les médias"
|
||||
_instanceTicker:
|
||||
none: "Cacher "
|
||||
|
||||
+1
-9
@@ -1040,6 +1040,7 @@ caption: "キャプション"
|
||||
loggedInAsBot: "Botアカウントでログイン中"
|
||||
tools: "ツール"
|
||||
cannotLoad: "読み込めません"
|
||||
cannotPreview: "プレビューできません"
|
||||
numberOfProfileView: "プロフィール表示回数"
|
||||
like: "いいね!"
|
||||
unlike: "いいねを解除"
|
||||
@@ -2084,8 +2085,6 @@ _role:
|
||||
isConditionalRole: "これはコンディショナルロールです。"
|
||||
isPublic: "公開ロール"
|
||||
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
|
||||
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
|
||||
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
|
||||
options: "オプション"
|
||||
policies: "ポリシー"
|
||||
baseRole: "ベースロール"
|
||||
@@ -3248,13 +3247,6 @@ _gridComponent:
|
||||
patternNotMatch: "この値は{pattern}のパターンに一致しません"
|
||||
notUnique: "この値は一意である必要があります"
|
||||
|
||||
_roleDisplay:
|
||||
title: "ロールの表示/非表示"
|
||||
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
|
||||
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
|
||||
displayToggle: "ロール/ロールバッジを表示する"
|
||||
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
|
||||
|
||||
_roleSelectDialog:
|
||||
notSelected: "選択されていません"
|
||||
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "加到最后"
|
||||
prepend: "加到最前"
|
||||
urlPreviewSensitiveList: "限制显示缩略图的 URL"
|
||||
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
|
||||
pixelatedZoom: "像素画放大模式"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "文件标题"
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "加在最後"
|
||||
prepend: "加在前面"
|
||||
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
|
||||
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
|
||||
pixelatedZoom: "像素藝術縮放模式"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "檔案標題"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RoleDisplayVisibility1783059479536 {
|
||||
name = 'RoleDisplayVisibility1783059479536';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`);
|
||||
await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`);
|
||||
|
||||
// 既存のロールについてはすべて強制表示とする(新規作成分についてはfalse)
|
||||
await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
|
||||
}
|
||||
}
|
||||
@@ -676,7 +676,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
target: values.target,
|
||||
condFormula: values.condFormula,
|
||||
isPublic: values.isPublic,
|
||||
isPublicDisplayRequired: values.isPublicDisplayRequired ?? false,
|
||||
isAdministrator: values.isAdministrator,
|
||||
isModerator: values.isModerator,
|
||||
isExplorable: values.isExplorable,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as nodeCrypto from 'crypto';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserKeypairsRepository } from '@/models/_.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
@@ -23,10 +24,10 @@ export class UserKeypairService implements OnApplicationShutdown {
|
||||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
|
||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair:v2', {
|
||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
||||
memoryCacheLifetime: 1000 * 60 * 60, // 1h
|
||||
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
|
||||
fetcher: (key) => this.fetcher(key),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
@@ -46,4 +47,18 @@ export class UserKeypairService implements OnApplicationShutdown {
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetcher(userId: MiUser['id']): Promise<MiUserKeypair> {
|
||||
const keyPair = await this.userKeypairsRepository.findOneByOrFail({ userId });
|
||||
|
||||
// migrate PKCS#1 => PKCS#8. legacy misskey generated PKCS#1 but slacc only accepts PKCS#8
|
||||
if (keyPair.privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
|
||||
const pkcs8Key = nodeCrypto.createPrivateKey({ key: keyPair.privateKey, format: 'pem', type: 'pkcs1' }).export({ format: 'pem', type: 'pkcs8' });
|
||||
keyPair.privateKey = pkcs8Key;
|
||||
void this.userKeypairsRepository.update(userId, { privateKey: pkcs8Key });
|
||||
}
|
||||
|
||||
return keyPair;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
makeNotesHiddenBefore: null,
|
||||
chatScope: 'mutual',
|
||||
emojis: [],
|
||||
hiddenRoleIds: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
inbox: null,
|
||||
|
||||
@@ -64,7 +64,6 @@ export class RoleEntityService {
|
||||
target: role.target,
|
||||
condFormula: role.condFormula,
|
||||
isPublic: role.isPublic,
|
||||
isPublicDisplayRequired: role.isPublicDisplayRequired,
|
||||
isAdministrator: role.isAdministrator,
|
||||
isModerator: role.isModerator,
|
||||
isExplorable: role.isExplorable,
|
||||
@@ -84,32 +83,5 @@ export class RoleEntityService {
|
||||
) {
|
||||
return Promise.all(roles.map(x => this.pack(x, me)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiRole['id'] | MiRole,
|
||||
): Promise<Packed<'RoleLite'>> {
|
||||
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
iconUrl: role.iconUrl,
|
||||
description: role.description,
|
||||
isModerator: role.isModerator,
|
||||
isAdministrator: role.isAdministrator,
|
||||
asBadge: role.asBadge,
|
||||
isPublicDisplayRequired: role.isPublicDisplayRequired,
|
||||
isExplorable: role.isExplorable,
|
||||
displayOrder: role.displayOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packLiteMany(
|
||||
roles: (MiRole | MiRole['id'])[],
|
||||
) {
|
||||
return Promise.all(roles.map(x => this.packLite(x)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||
import type { MiRole } from '@/models/Role.js';
|
||||
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import {
|
||||
birthdaySchema,
|
||||
@@ -52,7 +51,6 @@ import { ChatService } from '@/core/ChatService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
import type { RoleEntityService } from './RoleEntityService.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
@@ -90,7 +88,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
private apPersonService: ApPersonService;
|
||||
private noteEntityService: NoteEntityService;
|
||||
private pageEntityService: PageEntityService;
|
||||
private roleEntityService: RoleEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private announcementService: AnnouncementService;
|
||||
private roleService: RoleService;
|
||||
@@ -147,7 +144,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
this.apPersonService = this.moduleRef.get('ApPersonService');
|
||||
this.noteEntityService = this.moduleRef.get('NoteEntityService');
|
||||
this.pageEntityService = this.moduleRef.get('PageEntityService');
|
||||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||
this.announcementService = this.moduleRef.get('AnnouncementService');
|
||||
this.roleService = this.moduleRef.get('RoleService');
|
||||
@@ -406,30 +402,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
return `${this.config.url}/users/${userId}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private prepareRoles<T extends Pick<MiRole, 'id' | 'isPublic' | 'displayOrder' | 'isPublicDisplayRequired'>>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] {
|
||||
const hiddenRoleIds = new Set(user.hiddenRoleIds);
|
||||
return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private sanitizeHiddenRoleIds(roles: Pick<MiRole, 'id' | 'isPublic' | 'isPublicDisplayRequired'>[], user: MiUser): MiRole['id'][] {
|
||||
const hideableRoleIds = new Set(roles
|
||||
.filter(role => role.isPublic && !role.isPublicDisplayRequired)
|
||||
.map(role => role.id));
|
||||
const sanitizedRoleIds: MiRole['id'][] = [];
|
||||
const seenRoleIds = new Set<MiRole['id']>();
|
||||
|
||||
for (const roleId of user.hiddenRoleIds) {
|
||||
if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue;
|
||||
|
||||
sanitizedRoleIds.push(roleId);
|
||||
seenRoleIds.add(roleId);
|
||||
}
|
||||
|
||||
return sanitizedRoleIds;
|
||||
}
|
||||
|
||||
public async pack<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>(
|
||||
src: MiUser['id'] | MiUser,
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
@@ -453,7 +425,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId === user.id;
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null;
|
||||
|
||||
const profile = isDetailed
|
||||
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||
@@ -543,9 +514,10 @@ export class UserEntityService implements OnModuleInit {
|
||||
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: this.getOnlineStatus(user),
|
||||
// パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
|
||||
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator)
|
||||
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
|
||||
.filter((r) => r.isPublic || iAmModerator)
|
||||
.sort((a, b) => b.displayOrder - a.displayOrder)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
iconUrl: r.iconUrl,
|
||||
displayOrder: r.displayOrder,
|
||||
@@ -588,7 +560,16 @@ export class UserEntityService implements OnModuleInit {
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
chatScope: user.chatScope,
|
||||
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
|
||||
roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))),
|
||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
iconUrl: role.iconUrl,
|
||||
description: role.description,
|
||||
isModerator: role.isModerator,
|
||||
isAdministrator: role.isAdministrator,
|
||||
displayOrder: role.displayOrder,
|
||||
}))),
|
||||
memo: memo,
|
||||
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
|
||||
} : {}),
|
||||
@@ -617,7 +598,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
preventAiLearning: profile!.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)),
|
||||
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: false, // 後方互換性のため
|
||||
|
||||
@@ -227,11 +227,6 @@ export class MiRole {
|
||||
})
|
||||
public isPublic: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isPublicDisplayRequired: boolean;
|
||||
|
||||
// trueの場合ユーザー名の横にバッジとして表示
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import type { MiRole } from './Role.js';
|
||||
|
||||
@Entity('user')
|
||||
@Index(['usernameLower', 'host'], { unique: true })
|
||||
@@ -230,11 +229,6 @@ export class MiUser {
|
||||
})
|
||||
public emojis: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 32, array: true, default: '{}',
|
||||
})
|
||||
public hiddenRoleIds: MiRole['id'][];
|
||||
|
||||
// チャットを許可する相手
|
||||
// everyone: 誰からでも
|
||||
// followers: フォロワーのみ
|
||||
|
||||
@@ -369,21 +369,6 @@ export const packedRoleLiteSchema = {
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
asBadge: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isPublicDisplayRequired: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isExplorable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
displayOrder: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
@@ -427,11 +412,6 @@ export const packedRoleSchema = {
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isPublicDisplayRequired: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isExplorable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
||||
@@ -176,11 +176,6 @@ export const packedUserLiteSchema = {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
@@ -516,15 +511,6 @@ export const packedMeDetailedOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
hiddenRoleIds: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
twoFactorBackupCodesStock: {
|
||||
type: 'string',
|
||||
enum: ['full', 'partial', 'none'],
|
||||
|
||||
@@ -32,7 +32,6 @@ export const paramDef = {
|
||||
target: { type: 'string', enum: ['manual', 'conditional'] },
|
||||
condFormula: { type: 'object' },
|
||||
isPublic: { type: 'boolean' },
|
||||
isPublicDisplayRequired: { type: 'boolean', default: false },
|
||||
isModerator: { type: 'boolean' },
|
||||
isAdministrator: { type: 'boolean' },
|
||||
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
|
||||
|
||||
@@ -37,7 +37,6 @@ export const paramDef = {
|
||||
target: { type: 'string', enum: ['manual', 'conditional'] },
|
||||
condFormula: { type: 'object' },
|
||||
isPublic: { type: 'boolean' },
|
||||
isPublicDisplayRequired: { type: 'boolean' },
|
||||
isModerator: { type: 'boolean' },
|
||||
isAdministrator: { type: 'boolean' },
|
||||
isExplorable: { type: 'boolean' },
|
||||
@@ -76,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
target: ps.target,
|
||||
condFormula: ps.condFormula,
|
||||
isPublic: ps.isPublic,
|
||||
isPublicDisplayRequired: ps.isPublicDisplayRequired,
|
||||
isModerator: ps.isModerator,
|
||||
isAdministrator: ps.isAdministrator,
|
||||
isExplorable: ps.isExplorable,
|
||||
|
||||
@@ -191,12 +191,6 @@ export const paramDef = {
|
||||
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
|
||||
hiddenRoleIds: {
|
||||
type: 'array',
|
||||
maxItems: 256,
|
||||
uniqueItems: true,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
mutedWords: muteWords,
|
||||
hardMutedWords: muteWords,
|
||||
@@ -299,20 +293,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
|
||||
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
||||
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
||||
if (ps.hiddenRoleIds !== undefined) {
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id));
|
||||
const hiddenRoleIds: string[] = [];
|
||||
const seenRoleIds = new Set<string>();
|
||||
|
||||
for (const roleId of ps.hiddenRoleIds) {
|
||||
if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue;
|
||||
seenRoleIds.add(roleId);
|
||||
hiddenRoleIds.push(roleId);
|
||||
}
|
||||
|
||||
updates.hiddenRoleIds = hiddenRoleIds;
|
||||
}
|
||||
|
||||
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
|
||||
const count = (arr: (string[] | string)[]) => {
|
||||
|
||||
@@ -10,9 +10,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -83,7 +83,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
if (note.text == null) {
|
||||
let text = note.text ?? '';
|
||||
if (note.cw != null) {
|
||||
text = `${note.cw}\n-----\n${text}`;
|
||||
}
|
||||
|
||||
if (text.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('text', note.text);
|
||||
params.append('text', text);
|
||||
params.append('target_lang', targetLang);
|
||||
|
||||
const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
@@ -128,7 +128,6 @@ describe('ユーザー', () => {
|
||||
preventAiLearning: user.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
hiddenRoleIds: user.hiddenRoleIds ?? [],
|
||||
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
||||
@@ -661,16 +660,12 @@ describe('ユーザー', () => {
|
||||
description: rolePublic.description,
|
||||
isModerator: rolePublic.isModerator,
|
||||
isAdministrator: rolePublic.isAdministrator,
|
||||
asBadge: rolePublic.asBadge,
|
||||
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
|
||||
isExplorable: rolePublic.isExplorable,
|
||||
displayOrder: rolePublic.displayOrder,
|
||||
}]);
|
||||
});
|
||||
test('を取得することができ、バッヂロールがセットされていること', async () => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
|
||||
assert.deepStrictEqual(response.badgeRoles, [{
|
||||
id: roleBadge.id,
|
||||
name: roleBadge.name,
|
||||
iconUrl: roleBadge.iconUrl,
|
||||
displayOrder: roleBadge.displayOrder,
|
||||
@@ -683,42 +678,9 @@ describe('ユーザー', () => {
|
||||
description: roleBadge.description,
|
||||
isModerator: roleBadge.isModerator,
|
||||
isAdministrator: roleBadge.isAdministrator,
|
||||
asBadge: roleBadge.asBadge,
|
||||
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
|
||||
isExplorable: roleBadge.isExplorable,
|
||||
displayOrder: roleBadge.displayOrder,
|
||||
}]);
|
||||
});
|
||||
test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => {
|
||||
const user = await signup({ username: 'userHiddenRoles' });
|
||||
const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' });
|
||||
const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' });
|
||||
const privateRole = await role(root, { isPublic: false, name: 'Private Role' });
|
||||
const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' });
|
||||
const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' });
|
||||
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root);
|
||||
|
||||
const response = await successfulApiCall({
|
||||
endpoint: 'i/update',
|
||||
parameters: {
|
||||
hiddenRoleIds: [
|
||||
secondVisibleRole.id,
|
||||
'unknownroleid',
|
||||
unassignedRole.id,
|
||||
privateRole.id,
|
||||
forcedRole.id,
|
||||
visibleRole.id,
|
||||
],
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]);
|
||||
});
|
||||
test('をID指定のリスト形式で取得することができる(空)', async () => {
|
||||
const parameters = { userIds: [] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
|
||||
|
||||
@@ -14,10 +14,7 @@ import { genAidx } from '@/misc/id/aidx.js';
|
||||
import {
|
||||
BlockingsRepository,
|
||||
FollowingsRepository, FollowRequestsRepository,
|
||||
MiRole,
|
||||
MiUserProfile, MutingsRepository, RenoteMutingsRepository,
|
||||
RoleAssignmentsRepository,
|
||||
RolesRepository,
|
||||
UserMemoRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
@@ -30,7 +27,6 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
@@ -71,13 +67,6 @@ describe('UserEntityService', () => {
|
||||
let blockingRepository: BlockingsRepository;
|
||||
let mutingRepository: MutingsRepository;
|
||||
let renoteMutingsRepository: RenoteMutingsRepository;
|
||||
let rolesRepository: RolesRepository;
|
||||
let roleAssignmentsRepository: RoleAssignmentsRepository;
|
||||
let roleService: RoleService;
|
||||
type RoleServiceCacheController = {
|
||||
rolesCache: { delete(): void };
|
||||
roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void };
|
||||
};
|
||||
|
||||
async function createUser(userData: Partial<MiUser> = {}, profileData: Partial<MiUserProfile> = {}) {
|
||||
const un = secureRndstr(16);
|
||||
@@ -98,32 +87,6 @@ describe('UserEntityService', () => {
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createRole(roleData: Partial<MiRole> = {}) {
|
||||
const role = await rolesRepository
|
||||
.insert({
|
||||
id: genAidx(Date.now()),
|
||||
updatedAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
name: '',
|
||||
description: '',
|
||||
...roleData,
|
||||
})
|
||||
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
(roleService as unknown as RoleServiceCacheController).rolesCache.delete();
|
||||
return role;
|
||||
}
|
||||
|
||||
async function assignRole(user: MiUser, role: MiRole) {
|
||||
await roleAssignmentsRepository.insert({
|
||||
id: genAidx(Date.now()),
|
||||
userId: user.id,
|
||||
roleId: role.id,
|
||||
});
|
||||
|
||||
(roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id);
|
||||
}
|
||||
|
||||
async function memo(writer: MiUser, target: MiUser, memo: string) {
|
||||
await userMemosRepository.insert({
|
||||
id: genAidx(Date.now()),
|
||||
@@ -183,7 +146,6 @@ describe('UserEntityService', () => {
|
||||
ApPersonService,
|
||||
NoteEntityService,
|
||||
PageEntityService,
|
||||
RoleEntityService,
|
||||
CustomEmojiService,
|
||||
AnnouncementService,
|
||||
RoleService,
|
||||
@@ -234,9 +196,6 @@ describe('UserEntityService', () => {
|
||||
blockingRepository = app.get<BlockingsRepository>(DI.blockingsRepository);
|
||||
mutingRepository = app.get<MutingsRepository>(DI.mutingsRepository);
|
||||
renoteMutingsRepository = app.get<RenoteMutingsRepository>(DI.renoteMutingsRepository);
|
||||
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
|
||||
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
|
||||
roleService = app.get<RoleService>(RoleService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -290,60 +249,6 @@ describe('UserEntityService', () => {
|
||||
expect(actual.achievements).toEqual(achievements);
|
||||
});
|
||||
|
||||
test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => {
|
||||
const me = await createUser();
|
||||
const visibleRole = await createRole({ name: 'visible', isPublic: true });
|
||||
const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true });
|
||||
const privateRole = await createRole({ name: 'private', isPublic: false });
|
||||
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true });
|
||||
const unassignedRole = await createRole({ name: 'unassigned', isPublic: true });
|
||||
|
||||
await assignRole(me, visibleRole);
|
||||
await assignRole(me, secondVisibleRole);
|
||||
await assignRole(me, privateRole);
|
||||
await assignRole(me, forcedRole);
|
||||
await usersRepository.update(me.id, {
|
||||
hiddenRoleIds: [
|
||||
secondVisibleRole.id,
|
||||
'unknownroleid',
|
||||
unassignedRole.id,
|
||||
privateRole.id,
|
||||
forcedRole.id,
|
||||
visibleRole.id,
|
||||
secondVisibleRole.id,
|
||||
],
|
||||
});
|
||||
const updatedMe = await usersRepository.findOneByOrFail({ id: me.id });
|
||||
|
||||
const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' });
|
||||
|
||||
expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]);
|
||||
});
|
||||
|
||||
test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => {
|
||||
const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]);
|
||||
const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 });
|
||||
const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 });
|
||||
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 });
|
||||
const moderatorRole = await createRole({ name: 'moderator', isModerator: true });
|
||||
|
||||
await assignRole(target, hiddenRole);
|
||||
await assignRole(target, visibleRole);
|
||||
await assignRole(target, forcedRole);
|
||||
await assignRole(moderator, moderatorRole);
|
||||
await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] });
|
||||
const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id });
|
||||
|
||||
const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' });
|
||||
const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' });
|
||||
|
||||
expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
|
||||
expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
|
||||
expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true);
|
||||
expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
|
||||
expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
|
||||
});
|
||||
|
||||
test('alsoKnownAs as string does not throw', async () => {
|
||||
const me = await createUser();
|
||||
const who = await createUser();
|
||||
|
||||
@@ -357,7 +357,6 @@ export function role(params: {
|
||||
isPublic?: boolean,
|
||||
isExplorable?: boolean,
|
||||
asBadge?: boolean,
|
||||
isPublicDisplayRequired?: boolean,
|
||||
canEditMembersByModerator?: boolean,
|
||||
usersCount?: number,
|
||||
}, seed?: string): entities.Role {
|
||||
@@ -381,7 +380,6 @@ export function role(params: {
|
||||
isPublic: params.isPublic ?? true,
|
||||
isExplorable: params.isExplorable ?? true,
|
||||
asBadge: params.asBadge ?? true,
|
||||
isPublicDisplayRequired: params.isPublicDisplayRequired ?? false,
|
||||
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
|
||||
usersCount: params.usersCount ?? 10,
|
||||
preserveAssignmentOnMoveAccount: false,
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<!-- keyを変えて要素ごと作り直すことで、AudioContextに繋いだ要素を確実に切り離す (onLoadErrorを参照) -->
|
||||
<audio
|
||||
:key="audioElKey"
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
:crossorigin="crossOriginMode === 'anonymous' ? 'anonymous' : undefined"
|
||||
:src="content.url"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@error="onLoadError"
|
||||
></audio>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
width="1600"
|
||||
height="900"
|
||||
data-gallery-click-action="media"
|
||||
:class="$style.visualizer"
|
||||
v-bind="$attrs"
|
||||
></canvas>
|
||||
<div v-if="!isPlaying" :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, shallowRef, ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { themeManager } from '@/theme.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { store } from '@/store.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
|
||||
// クリック等のフォールスルーはキャンバスに渡す (ルートは全面を覆うので、その外側は背景として扱わせる)
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
content: Content;
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
isPlaying: boolean;
|
||||
volume: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'loadedmetadata'): void;
|
||||
}>();
|
||||
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
const canvasCtx = computed(() => canvasEl.value?.getContext('2d') ?? null);
|
||||
|
||||
const fileUser = computed(() => props.content.file?.user ?? props.user);
|
||||
|
||||
//#region 描画パラメータ
|
||||
// 低域しか見ないので、その範囲を十分な bin 数で刻めるよう大きめの FFT を使う (48kHz で bin 幅 ≒ 5.9Hz)
|
||||
const FFT_SIZE = 8192;
|
||||
const BIN_COUNT = FFT_SIZE / 2;
|
||||
|
||||
/** 波形の制御点数 (片側)。実際の輪郭はこれを左右にミラーした 2 倍の点で構成される */
|
||||
const BAND_COUNT = 36;
|
||||
/** スペクトルとして取り出す周波数レンジ。波形もビート判定もこの低域だけを見る */
|
||||
const MIN_FREQ = 30;
|
||||
const MAX_FREQ = 300;
|
||||
|
||||
/** 波形の立ち上がり / 立ち下がりの時定数 (s) */
|
||||
const WAVE_ATTACK_TAU = 0.01;
|
||||
const WAVE_RELEASE_TAU = 0.015;
|
||||
/** 正規化後にかけるコントラスト強調の指数 大きいほど小さい山が引っ込み、大きい山との差が開く */
|
||||
const CONTRAST_EXPONENT = 1.9;
|
||||
/** 基準半径に対する波形の振れ幅 */
|
||||
const WAVE_GAIN = 1.05;
|
||||
/** Catmull-Rom スプラインの張り具合 大きいほど丸く、0 に近いほど折れ線に近づく */
|
||||
const SPLINE_TENSION = 0.9;
|
||||
|
||||
/** 全体の音圧に追従する緩やかな拡大縮小の時定数 (s) と最大量 */
|
||||
const LEVEL_TAU = 0.3;
|
||||
const LEVEL_SCALE = 0.1;
|
||||
/** ビート検出用のエンベロープ / その移動平均の時定数 (s) */
|
||||
const BEAT_ATTACK_TAU = 0.012;
|
||||
const BEAT_FALL_TAU = 0.11;
|
||||
const BEAT_BASELINE_TAU = 0.7;
|
||||
/** 移動平均からの超過分をビートとみなす際の増幅率と、減衰の時定数 (s) */
|
||||
const BEAT_SENSITIVITY = 3;
|
||||
const BEAT_RELEASE_TAU = 0.18;
|
||||
/** ビートに合わせて瞬間的に拡大する量 */
|
||||
const BEAT_SCALE = 0.16;
|
||||
|
||||
/** キャンバス短辺の半分に対する、無音時の波形半径の比 (アバターの直径もこれに一致する) */
|
||||
const BASE_RADIUS_RATIO = 0.36;
|
||||
//#endregion
|
||||
|
||||
// アニメーション量を控えるべきかどうか(拡大縮小のみ抑制)
|
||||
const motionDamp = prefer.s.animation ? 1 : 0.3;
|
||||
|
||||
//#region 解析の状態
|
||||
const freqArray = new Uint8Array(BIN_COUNT);
|
||||
/** 帯域ごとに参照する bin の範囲 */
|
||||
const bandStart = new Uint16Array(BAND_COUNT);
|
||||
const bandEnd = new Uint16Array(BAND_COUNT);
|
||||
/** 帯域ごとの生の振幅 (フレーム内の作業用) */
|
||||
const rawLevels = new Float32Array(BAND_COUNT);
|
||||
/** 帯域ごとの平滑化済み振幅 (0-1) */
|
||||
const levels = new Float32Array(BAND_COUNT);
|
||||
/** 輪郭の頂点座標 */
|
||||
const pointsX = new Float64Array(BAND_COUNT * 2);
|
||||
const pointsY = new Float64Array(BAND_COUNT * 2);
|
||||
|
||||
/** 自動レンジ調整が追跡している下限 / 上限。低域だけを見るので全帯域で 1 つのレンジを共有する */
|
||||
let rangeFloor = 0;
|
||||
let rangeCeil = 0;
|
||||
let levelEnv = 0;
|
||||
let energyEnv = 0;
|
||||
let energyBaseline = 0;
|
||||
let beatEnv = 0;
|
||||
|
||||
function setupBands(sampleRate: number) {
|
||||
const ratio = MAX_FREQ / MIN_FREQ;
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const lowFreq = MIN_FREQ * Math.pow(ratio, i / BAND_COUNT);
|
||||
const highFreq = MIN_FREQ * Math.pow(ratio, (i + 1) / BAND_COUNT);
|
||||
const toBin = (freq: number) => freq * FFT_SIZE / sampleRate;
|
||||
const start = Math.min(Math.max(Math.floor(toBin(lowFreq)), 0), BIN_COUNT - 1);
|
||||
const end = Math.min(Math.max(Math.ceil(toBin(highFreq)), start + 1), BIN_COUNT);
|
||||
bandStart[i] = start;
|
||||
bandEnd[i] = end;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAnalysis() {
|
||||
levels.fill(0);
|
||||
rangeFloor = 0;
|
||||
rangeCeil = 0;
|
||||
levelEnv = 0;
|
||||
energyEnv = 0;
|
||||
energyBaseline = 0;
|
||||
beatEnv = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数平滑。フレームレートに依存しないよう、経過時間から係数を求める
|
||||
* @param tau 目標値の約 63% まで近づくのにかかる秒数
|
||||
*/
|
||||
function approach(current: number, target: number, tau: number, dt: number) {
|
||||
return current + (target - current) * (1 - Math.exp(-dt / tau));
|
||||
}
|
||||
|
||||
/** 下限が下がる / 上がるときの時定数 (s) 上げ側を遅くして、鳴っている間に基準が持ち上がらないようにする */
|
||||
const RANGE_FLOOR_FALL_TAU = 0.25;
|
||||
const RANGE_FLOOR_RISE_TAU = 4;
|
||||
/** 上限が下がるときの時定数 (s) 上がるときは即時 */
|
||||
const RANGE_CEIL_FALL_TAU = 1.4;
|
||||
/** レンジ幅の下限。これ以下に潰れたときに無理な増幅をしない */
|
||||
const MIN_RANGE_SPAN = 0.14;
|
||||
|
||||
/**
|
||||
* フレーム内の最小 / 最大値から、正規化に使うレンジを更新して幅を返す (自動レンジ調整)。
|
||||
* 音源ごとの音量差を吸収しつつ、下限をゆっくり、上限を即時に追従させて短時間のダイナミクスは残す
|
||||
*/
|
||||
function updateRange(frameMin: number, framePeak: number, dt: number) {
|
||||
rangeFloor = approach(rangeFloor, frameMin, frameMin < rangeFloor ? RANGE_FLOOR_FALL_TAU : RANGE_FLOOR_RISE_TAU, dt);
|
||||
// 上限は即座に持ち上げ、ゆっくり戻す。戻り先に下限 + 最小幅を混ぜて、レンジが潰れないようにする
|
||||
rangeCeil = framePeak > rangeCeil ? framePeak : approach(rangeCeil, Math.max(framePeak, rangeFloor + MIN_RANGE_SPAN), RANGE_CEIL_FALL_TAU, dt);
|
||||
return Math.max(rangeCeil - rangeFloor, MIN_RANGE_SPAN);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const defaultBgColor = themeManager.currentCompiledTheme?.accent ?? '#aaa';
|
||||
const accentColorHue = computed(() => tinycolor(fileUser.value?.avatarBlurhash ? extractAvgColorFromBlurhash(fileUser.value.avatarBlurhash) ?? defaultBgColor : defaultBgColor).toHsl().h);
|
||||
const bgColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.1;
|
||||
targetSaturation = 0.8;
|
||||
} else {
|
||||
targetLightness = 0.9;
|
||||
targetSaturation = 0.9;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
const fgColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.25;
|
||||
targetSaturation = 0.8;
|
||||
} else {
|
||||
targetLightness = 0.7;
|
||||
targetSaturation = 0.7;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
const messageColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.75;
|
||||
targetSaturation = 0.4;
|
||||
} else {
|
||||
targetLightness = 0.25;
|
||||
targetSaturation = 0.4;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
|
||||
// 読み込みが終わるまでは描画しない (読み込み完了時の描き直しは下のwatchで行う)
|
||||
const avatarImage = shallowRef<HTMLImageElement | null>(null);
|
||||
|
||||
//#region audio graph
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let audioSource: MediaElementAudioSourceNode | null = null;
|
||||
let analyserNode: AnalyserNode | null = null;
|
||||
let gainNode: GainNode | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
/** 解析用のオーディオグラフを組めたかどうか。組めなかった場合は波形の代わりに文言を出す */
|
||||
let isVisualizerAvailable = true;
|
||||
|
||||
// AnalyserNodeはCORS的に読めない音源に対して無音を返すが、その際に例外もイベントも発生しない。
|
||||
// しかも一度 createMediaElementSource() に渡した要素は元の出力に戻せないため、
|
||||
// 「読み込み方を先に決める」「駄目なら要素ごと作り直す」の二段構えにする
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url, window.location.href).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一オリジンの音源はCORS属性なしでも解析できる */
|
||||
const isSameOriginContent = computed(() => isSameOrigin(props.content.url));
|
||||
/** CORS付きでの読み込みに失敗した音源 */
|
||||
const corsFailedUrl = ref<string | null>(null);
|
||||
/** フォールバックで要素を作り直した後、再生を再開すべき音源 */
|
||||
let resumeAfterReloadUrl: string | null = null;
|
||||
|
||||
/** `anonymous`: CORS付きで読み込む (解析できる) / `none`: CORS無しで読み込む (同一オリジンでない場合は再生のみ) */
|
||||
const crossOriginMode = computed<'anonymous' | 'none'>(() => (isSameOriginContent.value || corsFailedUrl.value === props.content.url) ? 'none' : 'anonymous');
|
||||
const canUseAudioGraph = computed(() => isSameOriginContent.value || crossOriginMode.value === 'anonymous');
|
||||
|
||||
// 読み込み方が変わったときと音源が差し替わったときに<audio>を作り直す。
|
||||
// 同じ要素を2度 createMediaElementSource() に渡すことはできないので、
|
||||
// グラフの組み直しが要る場面では必ず新しい要素を用意する必要がある
|
||||
const audioElKey = computed(() => `${crossOriginMode.value}\n${props.content.url}`);
|
||||
|
||||
/** 現在の要素でメタデータまで到達できたか。到達していればCORSのチェックは通過している */
|
||||
let hasLoadedMetadata = false;
|
||||
/** 現在の要素に対して再生が要求されたか */
|
||||
let isPlayRequested = false;
|
||||
|
||||
function onLoadedMetadata() {
|
||||
hasLoadedMetadata = true;
|
||||
emit('loadedmetadata');
|
||||
}
|
||||
|
||||
function onLoadError() {
|
||||
// CORSヘッダを返さないサーバーでは crossorigin 付きの読み込みが失敗するが、
|
||||
// MediaErrorからは原因を判別できないので、理由を問わず一度だけCORS無しで読み直す
|
||||
// (本当に壊れているファイルなら再試行も失敗し、同じエラー状態に落ち着く)。
|
||||
// ただしメタデータまで読めていたならCORSは通過済みなので、再生中のネットワーク断や
|
||||
// デコード失敗を拾ってフォールバックしてしまわないようにする
|
||||
if (crossOriginMode.value !== 'anonymous' || hasLoadedMetadata) return;
|
||||
|
||||
// 読み込み前に失敗しているので再生位置は0のまま。再生の要求だけ引き継げばよい
|
||||
resumeAfterReloadUrl = isPlayRequested ? props.content.url : null;
|
||||
corsFailedUrl.value = props.content.url;
|
||||
}
|
||||
|
||||
let visualizerTickFrameId: number | null = null;
|
||||
let lastTickTimestamp = 0;
|
||||
|
||||
function resumeAudioCtx() {
|
||||
if (audioCtx == null || audioCtx.state !== 'suspended') return;
|
||||
audioCtx.resume().catch(err => {
|
||||
console.error('Failed to resume AudioContext:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function visualizerTick(timestamp: number) {
|
||||
// タブがバックグラウンドに回っている間は rAF が止まるので、復帰時の巨大な dt は 0.1s で頭打ちにする
|
||||
const dt = lastTickTimestamp === 0 ? 1 / 60 : Math.min((timestamp - lastTickTimestamp) / 1000, 0.1);
|
||||
lastTickTimestamp = timestamp;
|
||||
|
||||
draw(dt);
|
||||
|
||||
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
|
||||
}
|
||||
|
||||
function startVisualizerTick() {
|
||||
if (visualizerTickFrameId != null) return;
|
||||
lastTickTimestamp = 0;
|
||||
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
|
||||
}
|
||||
|
||||
function stopVisualizerTick() {
|
||||
if (visualizerTickFrameId == null) return;
|
||||
window.cancelAnimationFrame(visualizerTickFrameId);
|
||||
visualizerTickFrameId = null;
|
||||
lastTickTimestamp = 0;
|
||||
}
|
||||
|
||||
function setPlaying(playing: boolean) {
|
||||
if (playing && isVisualizerAvailable) {
|
||||
startVisualizerTick();
|
||||
} else {
|
||||
// 一時停止中はその時点の波形・スケールをそのまま保つ (キャンバスは最後に描いたフレームを保持する)
|
||||
stopVisualizerTick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用のオーディオグラフを組む。組めなかった場合は false を返す
|
||||
* (AudioContext を作れない環境や、要素が既に別の AudioContext に接続済みの場合など)
|
||||
*/
|
||||
function setupAudioGraph(el: HTMLAudioElement) {
|
||||
try {
|
||||
audioCtx = new AudioContext();
|
||||
analyserNode = audioCtx.createAnalyser();
|
||||
analyserNode.fftSize = FFT_SIZE;
|
||||
// FFT の窓自体が長く (≒ 170ms) 平滑がかかるうえ、時間ベースの平滑は analyse() 側で行うので弱めにする
|
||||
analyserNode.smoothingTimeConstant = 0.3;
|
||||
// 既定の -100〜-30dB は音楽素材に対して下が広すぎるので、実用レンジに寄せて 0-255 を使い切る
|
||||
analyserNode.minDecibels = -90;
|
||||
analyserNode.maxDecibels = -25;
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = props.volume;
|
||||
audioSource = audioCtx.createMediaElementSource(el);
|
||||
|
||||
// ビジュアライザー用 (解析するだけなので destination には繋がない)
|
||||
audioSource.connect(analyserNode);
|
||||
// 再生用
|
||||
audioSource.connect(gainNode).connect(audioCtx.destination);
|
||||
|
||||
setupBands(audioCtx.sampleRate);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to set up the audio graph for the visualizer:', err);
|
||||
teardownAudioGraph();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const el = audioEl.value;
|
||||
if (el == null) return;
|
||||
|
||||
// 前の音源の波形を持ち越さない
|
||||
resetAnalysis();
|
||||
|
||||
// CORS的に読めない音源をグラフに繋ぐと再生まで無音になるため、その場合は繋がずに素の再生に任せる
|
||||
isVisualizerAvailable = canUseAudioGraph.value && setupAudioGraph(el);
|
||||
|
||||
if (isVisualizerAvailable) {
|
||||
// 音量制御はGainNodeが担当するため、要素側は常に100%
|
||||
// (ミュートは音量0として表現する。要素をmutedにするとタップまで無音になり波形が消える)
|
||||
el.volume = 1;
|
||||
} else {
|
||||
// グラフを組めなかったときは要素側で音量を制御する (ビジュアライザは諦めるが再生はできる)
|
||||
el.volume = props.volume;
|
||||
}
|
||||
el.muted = false;
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
|
||||
el.addEventListener(type, listener, { signal });
|
||||
};
|
||||
|
||||
// 再生状態: メディア要素のイベントを唯一の情報源にすることで、このコンポーネント経由でない
|
||||
// 操作 (コントロール・キーボード・OSのメディアキー等) でも波形の描画と同期がとれる
|
||||
on('play', () => {
|
||||
isPlayRequested = true;
|
||||
resumeAudioCtx();
|
||||
});
|
||||
on('playing', () => setPlaying(true));
|
||||
on('waiting', () => setPlaying(false));
|
||||
on('pause', () => setPlaying(false));
|
||||
on('ended', () => setPlaying(false));
|
||||
on('emptied', () => setPlaying(false));
|
||||
|
||||
// 現在の要素の状態を取り込む (コンポーネントの準備前に再生が始まっている場合等)。
|
||||
if (!el.paused) resumeAudioCtx();
|
||||
setPlaying(!el.paused);
|
||||
|
||||
// フォールバックでaudio要素を作り直す前に再生が要求されていたなら、新しい要素で再生し直す
|
||||
if (resumeAfterReloadUrl === props.content.url) {
|
||||
resumeAfterReloadUrl = null;
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 波形が回らないケース (停止中、またはビジュアライザを描画できない場合) はここで一度だけ描く
|
||||
redrawIfStopped();
|
||||
}
|
||||
|
||||
function teardownAudioGraph() {
|
||||
audioSource?.disconnect();
|
||||
audioSource = null;
|
||||
analyserNode?.disconnect();
|
||||
analyserNode = null;
|
||||
gainNode?.disconnect();
|
||||
gainNode = null;
|
||||
if (audioCtx != null && audioCtx.state !== 'closed') {
|
||||
audioCtx.close().catch(err => {
|
||||
console.error('Failed to close AudioContext:', err);
|
||||
});
|
||||
}
|
||||
audioCtx = null;
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
stopVisualizerTick();
|
||||
teardownAudioGraph();
|
||||
isVisualizerAvailable = true;
|
||||
hasLoadedMetadata = false;
|
||||
isPlayRequested = false;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 解析
|
||||
/** 無音ゲート フレーム平均がこの下限からこの幅の間で 0→1 になる */
|
||||
const GATE_FLOOR = 0.02;
|
||||
const GATE_RANGE = 0.08;
|
||||
|
||||
/**
|
||||
* スペクトルを帯域ごとの振幅に落とし込み、エンベロープ (波形・音圧・ビート) を更新する
|
||||
*/
|
||||
function analyse(dt: number) {
|
||||
if (analyserNode == null) return;
|
||||
analyserNode.getByteFrequencyData(freqArray);
|
||||
|
||||
// 帯域ごとの生の振幅を求める
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
let max = 0;
|
||||
let total = 0;
|
||||
for (let bin = bandStart[i]; bin < bandEnd[i]; bin++) {
|
||||
const v = freqArray[bin];
|
||||
total += v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
const binCount = bandEnd[i] - bandStart[i];
|
||||
// 最大値寄りにブレンドすると、幅の広い帯域でも細いピークが平均に埋もれず輪郭が動く
|
||||
rawLevels[i] = (max * 0.7 + (total / binCount) * 0.3) / 255;
|
||||
}
|
||||
|
||||
// 隣接帯域どうしをならして山をなだらかにする
|
||||
let prev = rawLevels[0];
|
||||
let sum = 0;
|
||||
let frameMin = 1;
|
||||
let framePeak = 0;
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const current = rawLevels[i];
|
||||
const next = i + 1 < BAND_COUNT ? rawLevels[i + 1] : current;
|
||||
const smoothed = (prev + current * 3 + next) / 5;
|
||||
rawLevels[i] = smoothed;
|
||||
prev = current;
|
||||
|
||||
sum += smoothed;
|
||||
if (smoothed < frameMin) frameMin = smoothed;
|
||||
if (smoothed > framePeak) framePeak = smoothed;
|
||||
}
|
||||
|
||||
// 無音ゲート: 自動レンジ調整は微小なノイズも最大まで引き伸ばしてしまうので、
|
||||
// フレーム全体のエネルギーが無いときは強制的に閉じる
|
||||
const frameMean = sum / BAND_COUNT;
|
||||
const gate = Math.min(Math.max((frameMean - GATE_FLOOR) / GATE_RANGE, 0), 1);
|
||||
|
||||
const span = updateRange(frameMin, framePeak, dt);
|
||||
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const normalized = Math.min(Math.max((rawLevels[i] - rangeFloor) / span, 0), 1);
|
||||
const target = Math.pow(normalized, CONTRAST_EXPONENT) * gate;
|
||||
|
||||
levels[i] = approach(levels[i], target, target > levels[i] ? WAVE_ATTACK_TAU : WAVE_RELEASE_TAU, dt);
|
||||
}
|
||||
|
||||
// 低域全体のエネルギー。音圧による拡大縮小とビート検出の両方がこれを共有する
|
||||
const energy = Math.min(Math.max((frameMean - rangeFloor) / span, 0), 1) * gate;
|
||||
|
||||
// 全体の音圧: 緩やかに追従させ、曲の盛り上がりに合わせてじわっと拡大縮小させる
|
||||
levelEnv = approach(levelEnv, energy, LEVEL_TAU, dt);
|
||||
|
||||
// ビート: エネルギーが自身の移動平均をどれだけ超えたかで検出する
|
||||
energyEnv = approach(energyEnv, energy, energy > energyEnv ? BEAT_ATTACK_TAU : BEAT_FALL_TAU, dt);
|
||||
energyBaseline = approach(energyBaseline, energyEnv, BEAT_BASELINE_TAU, dt);
|
||||
const hit = Math.min(Math.max(energyEnv - energyBaseline, 0) * BEAT_SENSITIVITY, 1);
|
||||
beatEnv = hit > beatEnv ? hit : approach(beatEnv, hit, BEAT_RELEASE_TAU, dt);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 描画
|
||||
/**
|
||||
* 帯域の振幅を左右対称の閉じた輪郭として塗る
|
||||
*/
|
||||
function fillWave(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, baseRadius: number) {
|
||||
const total = BAND_COUNT * 2;
|
||||
|
||||
for (let k = 0; k < total; k++) {
|
||||
// 前半は上→下、後半はその鏡像で下→上をたどる
|
||||
const bandIndex = k < BAND_COUNT ? k : total - 1 - k;
|
||||
const radius = baseRadius * (1 + levels[bandIndex] * WAVE_GAIN);
|
||||
// 真上を起点に、半周を BAND_COUNT 等分する (両端を半ステップずらして点の重複を避ける)
|
||||
const angle = -Math.PI / 2 + Math.PI * (k + 0.5) / BAND_COUNT;
|
||||
pointsX[k] = centerX + radius * Math.cos(angle);
|
||||
pointsY[k] = centerY + radius * Math.sin(angle);
|
||||
}
|
||||
|
||||
// Catmull-Rom スプラインを 3 次ベジェに変換して描く (曲線が頂点そのものを通るので、単独の山が潰れない)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pointsX[0], pointsY[0]);
|
||||
for (let k = 0; k < total; k++) {
|
||||
const prev = (k - 1 + total) % total;
|
||||
const next = (k + 1) % total;
|
||||
const nextNext = (k + 2) % total;
|
||||
ctx.bezierCurveTo(
|
||||
pointsX[k] + (pointsX[next] - pointsX[prev]) * SPLINE_TENSION / 6,
|
||||
pointsY[k] + (pointsY[next] - pointsY[prev]) * SPLINE_TENSION / 6,
|
||||
pointsX[next] - (pointsX[nextNext] - pointsX[k]) * SPLINE_TENSION / 6,
|
||||
pointsY[next] - (pointsY[nextNext] - pointsY[k]) * SPLINE_TENSION / 6,
|
||||
pointsX[next],
|
||||
pointsY[next],
|
||||
);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dt 前フレームからの経過時間 (s) 0 を渡すと解析を進めず、現在の状態をそのまま描き直す
|
||||
*/
|
||||
function draw(dt: number) {
|
||||
const canvas = canvasEl.value;
|
||||
const ctx = canvasCtx.value;
|
||||
if (canvas == null || ctx == null) return;
|
||||
|
||||
if (dt > 0) analyse(dt);
|
||||
|
||||
ctx.fillStyle = bgColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
// 全体が拡大縮小する
|
||||
// (ビジュアライザを描画できない場合は解析が走らずエンベロープが 0 のままなので、自然に等倍になる)
|
||||
const scale = 1 + (levelEnv * LEVEL_SCALE + beatEnv * BEAT_SCALE) * motionDamp;
|
||||
const baseRadius = Math.min(centerX, centerY) * BASE_RADIUS_RATIO * scale;
|
||||
|
||||
if (isVisualizerAvailable) {
|
||||
ctx.fillStyle = fgColor.value;
|
||||
fillWave(ctx, centerX, centerY, baseRadius);
|
||||
}
|
||||
|
||||
// 波形の中心にアバターを円形にくりぬいて描画 (波形が出せない場合もアバターは出す)
|
||||
const avatar = avatarImage.value;
|
||||
if (avatar != null) {
|
||||
const avatarSize = baseRadius * 2;
|
||||
const avatarHeight = Math.max(avatar.height * (avatarSize / avatar.width), avatarSize);
|
||||
const avatarWidth = Math.max(avatar.width * (avatarSize / avatar.height), avatarSize);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatar, centerX - avatarWidth / 2, centerY - avatarHeight / 2, avatarWidth, avatarHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (!isVisualizerAvailable) {
|
||||
// 再生自体はできるので、アバターの下に文言を添えるだけに留める
|
||||
ctx.fillStyle = messageColor.value;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${Math.round(canvas.height * 0.055)}px ${window.getComputedStyle(canvas).fontFamily}`;
|
||||
ctx.fillText(i18n.ts.cannotPreview, centerX, centerY + baseRadius + canvas.height * 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止中は描画の機会が無いので、表示内容が変わったときに自前で描き直す */
|
||||
function redrawIfStopped() {
|
||||
if (visualizerTickFrameId == null) draw(0);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
watch(() => props.volume, (to) => {
|
||||
if (gainNode != null) {
|
||||
gainNode.gain.value = to;
|
||||
} else if (audioEl.value != null) {
|
||||
// グラフを組めなかったときの音量制御は要素側が担当する
|
||||
audioEl.value.volume = to;
|
||||
}
|
||||
});
|
||||
|
||||
watch([bgColor, fgColor], redrawIfStopped);
|
||||
|
||||
watch(() => fileUser.value?.avatarUrl, (avatarUrl) => {
|
||||
const img = new Image();
|
||||
img.addEventListener('load', () => {
|
||||
avatarImage.value = img;
|
||||
redrawIfStopped();
|
||||
}, { once: true });
|
||||
img.src = avatarUrl ?? '/static-assets/avatar.png';
|
||||
}, { immediate: true });
|
||||
|
||||
watch(audioEl, () => {
|
||||
teardown();
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
defineExpose({
|
||||
audioEl,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.visualizer {
|
||||
display: block;
|
||||
user-select: none;
|
||||
// 100cqw / 100cqhの基準はMkLightbox.item.vueの.contentWrapper (= paddingを除いた実際の表示領域)
|
||||
width: min(100cqw, calc(100cqh * 16 / 9));
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.playIconWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
// アイコン自体はクリックを受け取らないので、hoverは下のcanvas要素を経由して拾う
|
||||
.visualizer:hover ~ .playIconWrapper .playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,455 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 音量をメディア要素に適用しない(ビジュアライザー用) */
|
||||
externalVolumeControl?: boolean;
|
||||
}>(), {
|
||||
externalVolumeControl: false,
|
||||
});
|
||||
|
||||
const volume = defineModel<number>('volume', { required: true });
|
||||
|
||||
const mediaEl = inject(DI.mkLightboxItemMediaEl, shallowRef<HTMLVideoElement | HTMLAudioElement | null>(null));
|
||||
const isVideo = computed(() => mediaEl.value instanceof HTMLVideoElement);
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled && isVideo.value ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false); // ユーザーが再生中であることを期待する状態か
|
||||
const isActuallyPlaying = ref(false); // 実際に再生中か (バッファリング等で一時停止している場合は false)
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (durationMs.value === 0) return 0;
|
||||
return bufferedEnd.value / (durationMs.value / 1000);
|
||||
});
|
||||
|
||||
// state の更新はすべてメディア要素のイベント側に任せる
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
mediaEl.value?.pause();
|
||||
} else {
|
||||
// 自動再生のブロック等で reject しうるが、再生ボタンが出たままになるだけなので握りつぶす
|
||||
mediaEl.value?.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
// ブラウザ側で許可されていない場合等にrejectしうるが、表示が変わらないだけなので握りつぶす
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to exit picture-in-picture:', err);
|
||||
});
|
||||
} else if (isVideo.value) {
|
||||
(mediaEl.value as HTMLVideoElement).requestPictureInPicture().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to enter picture-in-picture:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let loopObserver: MutationObserver | null = null;
|
||||
|
||||
// currentTime だけは進捗を通知するイベントが timeupdate しかなく、
|
||||
// これは 4Hz 程度でしか発火しないためシークバーがカクつく。
|
||||
// そのため再生中に限り requestAnimationFrame で補間する
|
||||
let elapsedTickFrameId: number | null = null;
|
||||
|
||||
function syncElapsedTime() {
|
||||
if (mediaEl.value == null) return;
|
||||
elapsedTimeMs.value = mediaEl.value.currentTime * 1000;
|
||||
}
|
||||
|
||||
function elapsedTick() {
|
||||
syncElapsedTime();
|
||||
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
|
||||
}
|
||||
|
||||
function startElapsedTick() {
|
||||
if (elapsedTickFrameId != null) return;
|
||||
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
|
||||
}
|
||||
|
||||
function stopElapsedTick() {
|
||||
if (elapsedTickFrameId == null) return;
|
||||
window.cancelAnimationFrame(elapsedTickFrameId);
|
||||
elapsedTickFrameId = null;
|
||||
}
|
||||
|
||||
function syncDuration() {
|
||||
const duration = mediaEl.value?.duration;
|
||||
// メタデータ読み込み前は NaN、ライブストリームでは Infinity になりうる
|
||||
durationMs.value = duration != null && Number.isFinite(duration) ? duration * 1000 : 0;
|
||||
}
|
||||
|
||||
function syncBuffered() {
|
||||
const buffered = mediaEl.value?.buffered;
|
||||
if (buffered == null || buffered.length === 0) {
|
||||
bufferedEnd.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// シークすると読み込み済みの範囲が複数に分かれるため、最も先まで到達している位置を採用する
|
||||
let end = 0;
|
||||
for (let i = 0; i < buffered.length; i++) {
|
||||
if (buffered.end(i) > end) end = buffered.end(i);
|
||||
}
|
||||
bufferedEnd.value = end;
|
||||
}
|
||||
|
||||
function syncReady() {
|
||||
const el = mediaEl.value;
|
||||
isReady.value = el != null && el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const el: HTMLMediaElement | null = mediaEl.value;
|
||||
if (el == null) return;
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
|
||||
el.addEventListener(type, listener, { signal });
|
||||
};
|
||||
|
||||
on('play', () => {
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
startElapsedTick();
|
||||
});
|
||||
|
||||
on('playing', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
startElapsedTick();
|
||||
});
|
||||
|
||||
on('waiting', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
});
|
||||
|
||||
on('pause', () => {
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
});
|
||||
|
||||
on('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
syncElapsedTime();
|
||||
});
|
||||
|
||||
on('timeupdate', syncElapsedTime);
|
||||
on('seeking', syncElapsedTime);
|
||||
on('seeked', () => {
|
||||
syncElapsedTime();
|
||||
syncBuffered();
|
||||
});
|
||||
|
||||
on('durationchange', syncDuration);
|
||||
on('loadstart', syncReady);
|
||||
on('canplay', syncReady);
|
||||
on('canplaythrough', syncReady);
|
||||
on('loadedmetadata', () => {
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncReady();
|
||||
});
|
||||
on('progress', syncBuffered);
|
||||
on('emptied', () => {
|
||||
isReady.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
oncePlayed.value = false;
|
||||
stopElapsedTick();
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncElapsedTime();
|
||||
});
|
||||
|
||||
// ネイティブUIやブラウザのコンテキストメニューから変更されうるもの
|
||||
// (externalVolumeControl時は要素の音量を100%に固定しているので、取り込むと表示が壊れる)
|
||||
if (!props.externalVolumeControl) {
|
||||
on('volumechange', () => {
|
||||
const to = el.muted ? 0 : el.volume;
|
||||
if (volume.value !== to) volume.value = to;
|
||||
});
|
||||
}
|
||||
|
||||
on('ratechange', () => {
|
||||
if (speed.value !== el.playbackRate) speed.value = el.playbackRate;
|
||||
});
|
||||
|
||||
// loop には変更イベントが無いが、属性の変化を監視すればネイティブUI経由の変更も拾える
|
||||
loopObserver = new MutationObserver(() => {
|
||||
if (loop.value !== el.loop) loop.value = el.loop;
|
||||
});
|
||||
loopObserver.observe(el, { attributes: true, attributeFilter: ['loop'] });
|
||||
|
||||
// 現在の要素の状態を state に取り込む
|
||||
// (コントロール表示前に再生が始まっている場合等)
|
||||
syncReady();
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncElapsedTime();
|
||||
loop.value = el.loop;
|
||||
speed.value = el.playbackRate;
|
||||
isPlaying.value = !el.paused;
|
||||
if (!el.paused) {
|
||||
oncePlayed.value = true;
|
||||
startElapsedTick();
|
||||
}
|
||||
|
||||
if (!props.externalVolumeControl) {
|
||||
el.volume = volume.value;
|
||||
}
|
||||
|
||||
// 音声トラックを持たない動画はGIFのように扱う
|
||||
if (isVideo.value) {
|
||||
hasAudio(el).then(had => {
|
||||
// 判定を待っている間に teardown / 再 init されている可能性があるので、世代が変わっていたら何もしない
|
||||
if (signal.aborted) return;
|
||||
if (!had) {
|
||||
el.loop = el.muted = true;
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
loopObserver?.disconnect();
|
||||
loopObserver = null;
|
||||
stopElapsedTick();
|
||||
isReady.value = false;
|
||||
// メディア要素を差し替えた場合、古い要素のイベントはもう届かないのでここで戻しておく
|
||||
// (isPlaying / loop / speed 等は init() が新しい要素から取り込み直す)
|
||||
isActuallyPlaying.value = false;
|
||||
oncePlayed.value = false;
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (props.externalVolumeControl) return; // 適用は音量を受け取った側 (Web Audio経路) が行う
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.volume = to;
|
||||
mediaEl.value.muted = to === 0;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(mediaEl, () => {
|
||||
teardown();
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
defineExpose({
|
||||
isPlaying,
|
||||
isReady,
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="[$style.hiddenText, { [$style.withBlur]: content.type === 'video' && content.thumbnailUrl != null }]">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="content.file?.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
|
||||
<b v-else style="display: block;"><i class="ti" :class="content.type === 'image' ? 'ti-photo' : 'ti-movie'"></i> {{ content.type === 'image' ? i18n.ts.image : i18n.ts.video }}</b>
|
||||
<b v-else style="display: block;"><i class="ti" :class="contentHideFileIcon"></i> {{ contentHideFileText }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<video
|
||||
v-else-if="content.type === 'video'"
|
||||
ref="videoEl"
|
||||
data-gallery-click-action="video"
|
||||
data-gallery-click-action="media"
|
||||
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
|
||||
:src="content.url"
|
||||
:alt="content.file?.comment ?? undefined"
|
||||
@@ -90,16 +90,36 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
|
||||
playsinline
|
||||
@loadedmetadata="onVideoLoadedMetadata"
|
||||
@click.stop="onVideoClick"
|
||||
@click.stop="onMediaClick"
|
||||
></video>
|
||||
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
|
||||
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isMediaPlaying" :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="content.type === 'audio' && prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.audioRoot">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
:src="content.url"
|
||||
:alt="content.file?.comment ?? undefined"
|
||||
:class="$style.audio"
|
||||
controls
|
||||
@loadedmetadata="originalContentLoaded = true"
|
||||
></audio>
|
||||
</div>
|
||||
<XAudioVisualizer
|
||||
v-else-if="content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer"
|
||||
ref="audioVisualizer"
|
||||
:content="content"
|
||||
:user="user"
|
||||
:isPlaying="isMediaPlaying"
|
||||
:volume="volume"
|
||||
@click.stop="onMediaClick"
|
||||
@loadedmetadata="originalContentLoaded = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="activated && (!originalContentLoaded || (content.type === 'video' && isVideoPlaying && !isVideoActuallyPlaying))" :class="$style.loading">
|
||||
<div v-if="activated && (!originalContentLoaded || (isMediaControlledByMisskey && isMediaPlaying && (!isMediaReady || !isMediaActuallyPlaying)))" :class="$style.loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,8 +138,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
|
||||
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div v-if="content.type === 'video' && !hide && !prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.mediaControl">
|
||||
<MkVideoControl v-if="videoEl != null" ref="videoControl" :videoEl="videoEl"/>
|
||||
<div v-if="isMediaControlledByMisskey && !hide" :class="$style.mediaControl">
|
||||
<XControl v-if="mediaEl != null" ref="mediaControl" v-model:volume="volume" :externalVolumeControl="isVolumeHandledByVisualizer"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +160,7 @@ type Rect = Size & {
|
||||
|
||||
export type Content = {
|
||||
id: string;
|
||||
type: 'image' | 'video';
|
||||
type: 'image' | 'video' | 'audio';
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
width?: number | null;
|
||||
@@ -177,9 +197,10 @@ export function calculateSourceTransform({
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount } from 'vue';
|
||||
import MkVideoControl from '@/components/MkVideoControl.vue';
|
||||
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount, defineAsyncComponent } from 'vue';
|
||||
import MkBlurhash from '@/components/MkBlurhash.vue';
|
||||
import XControl from './MkLightbox.item.controls.vue';
|
||||
import type XAudioVisualizer__TypeReferenceOnly from './MkLightbox.item.audio-visualizer.vue';
|
||||
import XFileInfo from './MkLightbox.item.fileinfo.vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
@@ -194,10 +215,11 @@ import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: Content;
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
activated: boolean;
|
||||
initiallyOpened?: boolean;
|
||||
initiallyRevealed?: boolean;
|
||||
}>(), {
|
||||
initiallyOpened: false,
|
||||
initiallyRevealed: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -208,6 +230,8 @@ const emit = defineEmits<{
|
||||
(ev: 'cancelHorizontalSwipe'): void;
|
||||
}>();
|
||||
|
||||
const XAudioVisualizer = defineAsyncComponent(() => import('./MkLightbox.item.audio-visualizer.vue'));
|
||||
|
||||
// 一回のビューワー操作内では状態を維持するためにmodelで親に伝えて親で状態を保持する
|
||||
// TODO: drivefileのproperties側にピクセルアートかどうかのフラグを立ててそちらを元にデフォルトの挙動を決めるようにする
|
||||
const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
|
||||
@@ -215,19 +239,65 @@ const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const mainEl = useTemplateRef('mainEl');
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const videoControl = useTemplateRef('videoControl');
|
||||
const audioEl = useTemplateRef('audioEl'); // ネイティブUI時
|
||||
const audioVisualizer = useTemplateRef<InstanceType<typeof XAudioVisualizer__TypeReferenceOnly>>('audioVisualizer'); // ネイティブUIじゃない場合
|
||||
const mediaControl = useTemplateRef<InstanceType<typeof XControl>>('mediaControl');
|
||||
|
||||
provide(DI.mkLightboxItemVideoEl, videoEl);
|
||||
const mediaEl = computed<HTMLVideoElement | HTMLAudioElement | null>(() => {
|
||||
if (props.content.type === 'video') {
|
||||
return videoEl.value;
|
||||
} else if (props.content.type === 'audio') {
|
||||
if (prefer.s.useNativeUiForVideoAudioPlayer) {
|
||||
return audioEl.value;
|
||||
} else {
|
||||
return audioVisualizer.value?.audioEl ?? null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
provide(DI.mkLightboxItemMediaEl, mediaEl);
|
||||
|
||||
const originalContentLoaded = ref(false);
|
||||
const thumbnailContentLoaded = ref(false);
|
||||
const enableTransition = ref(false);
|
||||
const infoShowing = ref(false);
|
||||
const hide = ref(true);
|
||||
const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
|
||||
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
|
||||
const isMediaControlledByMisskey = computed(() => ['video', 'audio'].includes(props.content.type) && !prefer.s.useNativeUiForVideoAudioPlayer);
|
||||
// ビジュアライザー使用時は音量の適用をGainNode側が担当する (メディア要素は100%固定にして、波形が音量レベルに依存しないようにするため)
|
||||
const isVolumeHandledByVisualizer = computed(() => props.content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer);
|
||||
const volume = ref(0.25);
|
||||
const isMediaReady = computed(() => mediaControl.value?.isReady ?? false);
|
||||
const isMediaPlaying = computed(() => mediaControl.value?.isPlaying ?? false);
|
||||
const isMediaActuallyPlaying = computed(() => mediaControl.value?.isActuallyPlaying ?? false);
|
||||
let canOpenAnimation = false;
|
||||
|
||||
const contentHideFileIcon = computed(() => {
|
||||
switch (props.content.type) {
|
||||
case 'image':
|
||||
return 'ti-photo';
|
||||
case 'video':
|
||||
return 'ti-movie';
|
||||
case 'audio':
|
||||
return 'ti-music';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const contentHideFileText = computed(() => {
|
||||
switch (props.content.type) {
|
||||
case 'image':
|
||||
return i18n.ts.image;
|
||||
case 'video':
|
||||
return i18n.ts.video;
|
||||
case 'audio':
|
||||
return i18n.ts.audio;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const videoAspectRatio = ref<number | null>(
|
||||
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
|
||||
? props.content.width / props.content.height
|
||||
@@ -244,7 +314,7 @@ function onVideoLoadedMetadata() {
|
||||
}
|
||||
|
||||
const headerSize = 30;
|
||||
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
|
||||
const footerSize = isMediaControlledByMisskey.value ? 80 : 0;
|
||||
|
||||
const padding = deviceKind === 'smartphone' ? {
|
||||
top: Math.max(0, headerSize + 10),
|
||||
@@ -302,8 +372,8 @@ function shouldHideInGallery(content: Content): boolean {
|
||||
const hiddenByDefault = shouldHideFileByDefault(content.file, true);
|
||||
if (!hiddenByDefault) return false;
|
||||
|
||||
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
|
||||
if (content.file.isSensitive && props.initiallyOpened) {
|
||||
// 呼び出し元で既にぼかしが解除されているものは初期表示で隠さない
|
||||
if (props.initiallyRevealed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -483,7 +553,7 @@ const VELOCITY_WINDOW = 100;
|
||||
|
||||
let isDragging = false;
|
||||
let isClick = false;
|
||||
let clickAction: 'hidden' | 'video' | null = null;
|
||||
let clickAction: 'hidden' | 'media' | null = null;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let currentPointerId: number | null = null;
|
||||
@@ -527,11 +597,11 @@ function getVelocity(now: number): { x: number; y: number } {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClickAction(target: EventTarget | null): 'hidden' | 'video' | null {
|
||||
function resolveClickAction(target: EventTarget | null): 'hidden' | 'media' | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
|
||||
const action = target.closest('[data-gallery-click-action]')?.getAttribute('data-gallery-click-action');
|
||||
if (action === 'hidden' || action === 'video') {
|
||||
if (action === 'hidden' || action === 'media') {
|
||||
return action;
|
||||
}
|
||||
|
||||
@@ -831,8 +901,8 @@ function onClick(ev: MouseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'video') {
|
||||
onVideoClick();
|
||||
if (action === 'media') {
|
||||
onMediaClick();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -846,25 +916,49 @@ function onClick(ev: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* play() は自動再生のブロックや、再生が始まる前の pause() によって reject することがある。
|
||||
* いずれも再生ボタンが出たままになるだけで復帰不能ではないので、握りつぶす
|
||||
*/
|
||||
function safePlay(el: HTMLMediaElement) {
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function playWhenAvailable() {
|
||||
if (mediaEl.value != null) {
|
||||
safePlay(mediaEl.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// オーディオビジュアライザはlazy-loadのため、この時点ではまだ要素が無い可能性がある
|
||||
const watchStop = watch(mediaEl, (newMediaEl) => {
|
||||
if (newMediaEl == null) return;
|
||||
safePlay(newMediaEl);
|
||||
watchStop();
|
||||
});
|
||||
}
|
||||
|
||||
async function onHiddenClick() {
|
||||
if (hide.value) {
|
||||
if (props.content.file == null || await canRevealFile(props.content.file)) {
|
||||
hide.value = false;
|
||||
if (props.content.type === 'video' && videoEl.value != null) {
|
||||
videoEl.value.play();
|
||||
if (['audio', 'video'].includes(props.content.type)) {
|
||||
playWhenAvailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoClick() {
|
||||
function onMediaClick() {
|
||||
if (!prefer.s.useNativeUiForVideoAudioPlayer) {
|
||||
if (videoEl.value == null) return;
|
||||
if (mediaEl.value == null) return;
|
||||
|
||||
if (videoEl.value.paused) {
|
||||
videoEl.value.play();
|
||||
if (mediaEl.value.paused) {
|
||||
safePlay(mediaEl.value);
|
||||
} else {
|
||||
videoEl.value.pause();
|
||||
mediaEl.value.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -883,12 +977,16 @@ function openMenu(ev: PointerEvent) {
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
if (props.content.type === 'image') {
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.pixelatedZoom,
|
||||
icon: 'ti ti-grain',
|
||||
ref: pixelatedZoom,
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.pixelatedZoom,
|
||||
icon: 'ti ti-grain',
|
||||
ref: pixelatedZoom,
|
||||
}, {
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
@@ -908,9 +1006,7 @@ function openMenu(ev: PointerEvent) {
|
||||
}
|
||||
|
||||
function onActive() {
|
||||
if (videoEl.value != null) {
|
||||
videoEl.value.play();
|
||||
}
|
||||
playWhenAvailable();
|
||||
}
|
||||
|
||||
function onDeactive() {
|
||||
@@ -918,8 +1014,8 @@ function onDeactive() {
|
||||
isZooming.value = false;
|
||||
resetToNeutral();
|
||||
}
|
||||
if (videoEl.value != null && props.activated) {
|
||||
videoEl.value.pause();
|
||||
if (mediaEl.value != null && props.activated) {
|
||||
mediaEl.value.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,6 +1084,20 @@ defineExpose({
|
||||
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
|
||||
}
|
||||
|
||||
.audioRoot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: calc(100vw - 140px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -30,7 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
|
||||
v-model:pixelatedZoom="pixelatedZoom"
|
||||
:content="content"
|
||||
:initiallyOpened="i === (props.defaultIndex ?? 0)"
|
||||
:user="user"
|
||||
:initiallyRevealed="props.initiallyRevealedContentIds?.includes(content.id) ?? false"
|
||||
:activated="activatedIndexes.has(i)"
|
||||
@close="onItemClose"
|
||||
@horizontalSwipe="onHorizontalSwipe"
|
||||
@@ -50,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XItem from './MkLightbox.item.vue';
|
||||
import type { Content } from './MkLightbox.item.vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
@@ -61,6 +63,8 @@ import { focusTrap } from '@/utility/focus-trap.js';
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultIndex?: number;
|
||||
contents: Content[];
|
||||
initiallyRevealedContentIds?: string[];
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
||||
@@ -6,14 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.audioContainer,
|
||||
$style.root,
|
||||
(audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="reveal">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
@@ -22,140 +19,84 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
controls
|
||||
:class="$style.nativeAudio"
|
||||
@keydown.prevent
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.audioControls">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
v-panel
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:class="$style.audioRoot"
|
||||
@click="emit('mediaClick', $event)"
|
||||
@keydown.enter.prevent="emit('mediaClick', $event)"
|
||||
@keydown.space.prevent="emit('mediaClick', $event)"
|
||||
>
|
||||
<div :class="$style.audioRootSkelton"></div>
|
||||
<svg version="1.1" viewBox="0 0 2911.5 735.42" xmlns="http://www.w3.org/2000/svg" :class="$style.audioWave">
|
||||
<path d="m2852.4 363.63h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65z"/>
|
||||
<path d="m1966.5 371.8h-21.65v-8.17h21.65zm29.53-45.33h-21.65v82.48h21.65zm29.53-51.19h-21.65v184.86h21.65z"/>
|
||||
<path d="m2468.6 363.63h-21.65v8.17h21.65zm-29.53-158.13h-21.65v324.42h21.65zm-29.53-85.62h-21.65v495.67h21.65z"/>
|
||||
<path d="m2320.9 52.6h-21.65v630.22h21.65zm59.06 0h-21.65v630.22h21.65zm-29.53-52.6h-21.65v735.42h21.65v-735.41z"/>
|
||||
<path d="m2498.1 320.48h-21.65v94.47h21.65zm59.06-200.6h-21.65v495.67h21.65zm-29.53 114.98h-21.65v265.71h21.65z"/>
|
||||
<path d="m2084.7 234.86h-21.65v265.71h21.65zm29.53-89.29h-21.65v444.3h21.65zm-59.06 71.31h-21.65v301.67h21.65z"/>
|
||||
<path d="m2202.8 363.63h-21.65v8.17h21.65zm-59.06-139.94h-21.65v288.05h21.65zm29.53 87.58h-21.65v112.9h21.65z"/>
|
||||
<path d="m2645.7 262.99h-21.65v209.44h21.65zm-59.06-117.43h-21.65v444.3h21.65v-444.29zm29.53 149.23h-21.65v145.84h21.65z"/>
|
||||
<path d="m2704.8 262.99h-21.65v209.44h21.65zm-29.53-28.13h-21.65v265.71h21.65zm59.06 59.94h-21.65v145.84h21.65z"/>
|
||||
<path d="m2822.9 349.18h-21.65v37.07h21.65zm-29.53-28.7h-21.65v94.47h21.65zm-29.53-25.69h-21.65v145.84h21.65z"/>
|
||||
<path d="m2261.8 171.25h-21.65v392.92h21.65zm29.53-51.37h-21.65v495.67h21.65zm-59.06 174.92h-21.65v145.84h21.65z"/>
|
||||
<path d="m297.93 374.56h-22.45v-8.48h22.45zm30.61-8.48h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
|
||||
<path d="m940.74 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
|
||||
<path d="m512.2 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm-61.22-62.42h-22.44v133.32h22.44z"/>
|
||||
<path d="m573.42 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61-62.42h-22.44v133.32h22.44z"/>
|
||||
<path d="m114.27 222.53h-22.44v295.59h22.44zm61.22 0h-22.44v295.59h22.44zm-30.61-39.7h-22.44v375h22.44v-375.01z"/>
|
||||
<path d="m818.29 222.53h-22.44v295.59h22.44zm-61.22-129.47h-22.44v554.53h22.44zm30.61-36.25h-22.44v627.03h22.44z"/>
|
||||
<path d="m22.44 20.55h-22.44v699.54h22.44zm30.61 36.25h-22.44v627.04h22.44v-627.03zm30.61 210.61h-22.44v205.82h22.44z"/>
|
||||
<path d="m267.31 303.66h-22.44v133.32h22.44zm-61.22-36.25h-22.44v205.82h22.44zm30.61 0h-22.44v205.82h22.44z"/>
|
||||
<path d="m389.75 303.66h-22.44v133.32h22.44zm30.61 0h-22.44v133.32h22.44zm30.61-36.25h-22.44v205.82h22.44z"/>
|
||||
<path d="m726.46 141.39h-22.44v457.86h22.44zm-30.61 41.43h-22.44v375h22.44zm-30.61 84.59h-22.44v205.82h22.44z"/>
|
||||
<path d="m910.12 222.53h-22.44v295.59h22.44zm-61.22-39.7h-22.44v375h22.44v-375.01zm30.61 84.59h-22.44v205.82h22.44z"/>
|
||||
<path d="m1286.3 524.19h-19.72v-307.49h19.72zm-30.59-388.64h-19.72v469.79h19.72zm-30.59-63.76h-19.72v597.32h19.72z"/>
|
||||
<path d="m1133.4 605.34h-19.72v-469.79h19.72zm30.59-533.55h-19.72v597.32h19.72zm30.59-49.85h-19.72v697.02h19.72z"/>
|
||||
<path d="m1500.5 469.7h-19.72v-198.51h19.72zm61.18-198.51h-19.72v198.51h19.72zm-30.59-26.66h-19.72v251.84h19.72z"/>
|
||||
<path d="m1408.7 605.34h-19.72v-469.79h19.72zm30.59-445.45h-19.72v421.1h19.72v-421.09zm30.59 141.44h-19.72v138.23h19.72z"/>
|
||||
<path d="m1653.4 415.21h-19.72v-89.53h19.72zm-61.18-113.88h-19.72v138.23h19.72zm30.59 0h-19.72v138.23h19.72z"/>
|
||||
<path d="m1775.8 415.21h-19.72v-89.53h19.72zm30.59-89.53h-19.72v89.53h19.72zm30.59-24.35h-19.72v138.23h19.72z"/>
|
||||
<path d="m1102.8 556.65h-19.72v-372.41h19.72zm-30.59-255.32h-19.72v138.23h19.72zm-30.59 64.89h-19.72v8.45h19.72z"/>
|
||||
<path d="m1347.5 415.21h-19.72v-89.53h19.72zm30.59-170.69h-19.72v251.84h19.72v-251.83zm-61.18 121.7h-19.72v8.45h19.72z"/>
|
||||
<path d="m1684 374.67h-19.72v-8.45h19.72zm30.59-8.45h-19.72v8.45h19.72zm30.59 0h-19.72v8.45h19.72z"/>
|
||||
<path d="m1867.6 415.21h-19.72v-89.53h19.72zm61.18-48.99h-19.72v8.45h19.72zm-30.59 0h-19.72v8.45h19.72z"/>
|
||||
</svg>
|
||||
<div :class="$style.audioText">
|
||||
<i class="ti ti-music"></i> {{ i18n.ts.audio }}
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="() => {}"
|
||||
@mousedown.prevent.stop="showMenu"
|
||||
>
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
<div :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsVolume]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="toggleMute"
|
||||
>
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
</div>
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:class="$style.seekbarRoot"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @keydown.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
|
||||
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @keydown.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
audio: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
'down': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'left': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'right': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||
}
|
||||
},
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
if (!playerEl.value) return false;
|
||||
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
|
||||
}
|
||||
|
||||
const playerEl = useTemplateRef('playerEl');
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent | KeyboardEvent): void;
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref(shouldHideFileByDefault(props.audio));
|
||||
|
||||
async function reveal() {
|
||||
@@ -166,219 +107,30 @@ async function reveal() {
|
||||
hide.value = false;
|
||||
}
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
menu.push(...getFileMenu(props.audio, (newState) => {
|
||||
hide.value = newState;
|
||||
}));
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
function showMenu(ev: PointerEvent) {
|
||||
os.popupMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (!audioEl.value) return;
|
||||
audioEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (!audioEl.value) return 0;
|
||||
return bufferedEnd.value / audioEl.value.duration;
|
||||
});
|
||||
|
||||
// MediaControl Events
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value || !audioEl.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioEl.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
audioEl.value.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), ev);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopAudioElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
if (onceInit) return;
|
||||
onceInit = true;
|
||||
|
||||
stopAudioElWatch = watch(audioEl, () => {
|
||||
if (audioEl.value) {
|
||||
isReady.value = true;
|
||||
|
||||
function updateMediaTick() {
|
||||
if (audioEl.value) {
|
||||
try {
|
||||
bufferedEnd.value = audioEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
|
||||
|
||||
if (audioEl.value.loop !== loop.value) {
|
||||
loop.value = audioEl.value.loop;
|
||||
}
|
||||
}
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
audioEl.value.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
});
|
||||
|
||||
audioEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
audioEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
durationMs.value = audioEl.value.duration * 1000;
|
||||
audioEl.value.addEventListener('durationchange', () => {
|
||||
if (audioEl.value) {
|
||||
durationMs.value = audioEl.value.duration * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioEl.value.volume = volume.value;
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (audioEl.value) audioEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (audioEl.value) audioEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (audioEl.value) audioEl.value.loop = to;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isReady.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
elapsedTimeMs.value = 0;
|
||||
durationMs.value = 0;
|
||||
bufferedEnd.value = 0;
|
||||
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore');
|
||||
stopAudioElWatch();
|
||||
onceInit = false;
|
||||
if (mediaTickFrameId) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.audioContainer {
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
border: .5px solid var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
overflow: clip;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
@@ -399,6 +151,7 @@ onDeactivated(() => {
|
||||
|
||||
.hidden {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -417,86 +170,85 @@ onDeactivated(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"left time . volume right"
|
||||
"seekbar seekbar seekbar seekbar seekbar";
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
font-size: 1.05rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--MI_THEME-accent);
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
grid-area: time;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
grid-area: volume;
|
||||
|
||||
.volumeSeekbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.seekbarRoot {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.audioControls {
|
||||
grid-template-areas: "left seekbar time volume right";
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
.volumeSeekbar {
|
||||
max-width: 90px;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nativeAudioContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.nativeAudio {
|
||||
display: block;
|
||||
.audioRoot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.audioRootSkelton {
|
||||
position: relative;
|
||||
width: min(100cqw, calc(100cqh * 16 / 9));
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audioWave {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
fill: color(from var(--MI_THEME-fg) srgb r g b / 0.5);
|
||||
}
|
||||
|
||||
.audioText {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
font-size: 0.85em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIconWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menuBottom {
|
||||
border-radius: 8px 0 8px 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.menuTop {
|
||||
border-radius: 0 8px 0 8px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
|
||||
<div v-else-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<div v-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
@@ -27,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkMediaAudio from '@/components/MkMediaAudio.vue';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MediaComponentExposes } from '@/types/media-component.js';
|
||||
import { getStaticImageUrl } from '@/utility/media-proxy.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
@@ -138,6 +139,10 @@ function showMenu(ev: PointerEvent) {
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
|
||||
}
|
||||
|
||||
defineExpose<MediaComponentExposes>({
|
||||
isRevealed: () => !hide.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
|
||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
|
||||
<XBanner v-for="media in medias.nonPreviewable" :key="media.id" :media="media"/>
|
||||
<div v-if="count > 0" :class="$style.container">
|
||||
<div
|
||||
ref="gallery"
|
||||
:class="[
|
||||
@@ -19,10 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
}] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
|
||||
]"
|
||||
>
|
||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||
<template v-for="media in medias.previewable">
|
||||
<XAudio
|
||||
v-if="media.type.startsWith('audio')"
|
||||
:key="`audio:${media.id}`"
|
||||
:class="$style.media"
|
||||
:audio="media"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
/>
|
||||
<XVideo
|
||||
v-if="media.type.startsWith('video')"
|
||||
:key="`video:${media.id}`"
|
||||
:ref="(comp) => { mediaComponents.set(media.id, comp as InstanceType<typeof XVideo> | null); }"
|
||||
:class="$style.media"
|
||||
:video="media"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
@@ -30,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XImage
|
||||
v-else-if="media.type.startsWith('image')"
|
||||
:key="`image:${media.id}`"
|
||||
:ref="(comp) => { mediaComponents.set(media.id, comp as InstanceType<typeof XImage> | null); }"
|
||||
:marker="`${markerId}:${media.id}`"
|
||||
:disableImageLink="true"
|
||||
:class="$style.media"
|
||||
@@ -46,22 +55,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import type { MediaComponentExposes } from '@/types/media-component.js';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XAudio from '@/components/MkMediaAudio.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
raw?: boolean;
|
||||
}>();
|
||||
|
||||
const gallery = useTemplateRef('gallery');
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
const medias = computed(() => {
|
||||
const previewable: Misskey.entities.DriveFile[] = [];
|
||||
const nonPreviewable: Misskey.entities.DriveFile[] = [];
|
||||
for (const file of props.mediaList) {
|
||||
if (isPreviewable(file.type)) {
|
||||
previewable.push(file);
|
||||
} else {
|
||||
nonPreviewable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previewable,
|
||||
nonPreviewable,
|
||||
};
|
||||
});
|
||||
const mediaComponents = new Map<string, MediaComponentExposes | null>();
|
||||
const count = computed(() => medias.value.previewable.length);
|
||||
const markerId = genId();
|
||||
|
||||
async function calcAspectRatio() {
|
||||
@@ -102,14 +131,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaComponents.clear();
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
if (prefer.s.imageNewTab) {
|
||||
window.open(file.url, '_blank');
|
||||
@@ -120,7 +144,7 @@ function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
|
||||
async function openGallery(id?: string) {
|
||||
if (id == null) {
|
||||
const firstImage = props.mediaList.find(media => previewable(media));
|
||||
const firstImage = medias.value.previewable[0];
|
||||
if (firstImage == null) return;
|
||||
id = firstImage.id;
|
||||
}
|
||||
@@ -132,9 +156,9 @@ async function openGallery(id?: string) {
|
||||
return markRaw(found);
|
||||
};
|
||||
|
||||
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
|
||||
const contents = medias.value.previewable.map<Content>(media => ({
|
||||
id: media.id,
|
||||
type: media.type.startsWith('video') ? 'video' : 'image',
|
||||
type: getType(media.type),
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.properties.width,
|
||||
@@ -144,9 +168,15 @@ async function openGallery(id?: string) {
|
||||
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
|
||||
}));
|
||||
|
||||
const initiallyRevealedContentIds = contents
|
||||
.filter(content => mediaComponents.get(content.id)?.isRevealed() === true)
|
||||
.map(content => content.id);
|
||||
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
|
||||
defaultIndex: contents.findIndex(conten => conten.id === id),
|
||||
contents: contents,
|
||||
initiallyRevealedContentIds,
|
||||
user: props.user,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MediaComponentExposes } from '@/types/media-component.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
@@ -83,6 +84,10 @@ function showMenu(ev: PointerEvent) {
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
|
||||
}
|
||||
|
||||
defineExpose<MediaComponentExposes>({
|
||||
isRevealed: () => !hide.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
|
||||
</div>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
|
||||
@@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="$style.noteHeaderUsername">
|
||||
<MkAcct :user="appearNote.user"/>
|
||||
</div>
|
||||
<div v-if="badgeRoles" :class="$style.noteHeaderBadgeRoles">
|
||||
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
|
||||
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
|
||||
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
@@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
|
||||
</div>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
@@ -251,7 +251,6 @@ import { Paginator } from '@/utility/paginator.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { $i } from '@/i.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
@@ -333,14 +332,6 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
// MkNoteDetailed固有
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
const badgeRoles = computed(() => {
|
||||
const roles = appearNote.user.badgeRoles;
|
||||
if (roles == null || $i == null || $i.id !== appearNote.userId) return roles;
|
||||
|
||||
// 自分のノートを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
|
||||
const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []);
|
||||
return roles.filter(role => !hiddenRoleIds.has(role.id));
|
||||
});
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
|
||||
@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||
<div :class="$style.username"><MkAcct :user="note.user"/></div>
|
||||
<div v-if="badgeRoles" :class="$style.badgeRoles">
|
||||
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
|
||||
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
<div :class="$style.info">
|
||||
<div v-if="mock">
|
||||
@@ -35,26 +35,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { inject } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { DI } from '@/di.js';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const badgeRoles = computed(() => {
|
||||
const roles = props.note.user.badgeRoles;
|
||||
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
|
||||
|
||||
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
|
||||
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
|
||||
return roles.filter(role => !hiddenRoleIds.has(role.id));
|
||||
});
|
||||
const mock = inject(DI.mock, false);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="_selectable">
|
||||
<XValue :value="value" :collapsed="false"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,8 @@ import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Misskey.entities.DriveFile[];
|
||||
@@ -152,9 +154,6 @@ async function describe(file: Misskey.entities.DriveFile) {
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | KeyboardEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
@@ -171,14 +170,14 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
|
||||
action: () => { describe(file); },
|
||||
});
|
||||
|
||||
if (isImage || isVideo) {
|
||||
if (isPreviewable(file.type)) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const constents = props.modelValue.filter(item => item.type.startsWith('image') || item.type.startsWith('video')).map(item => ({
|
||||
const constents = props.modelValue.filter(item => isPreviewable(item.type)).map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: item.type.startsWith('video') ? 'video' as const : 'image' as const,
|
||||
type: getType(item.type),
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
width: item.properties.width,
|
||||
@@ -190,6 +189,7 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
|
||||
defaultIndex: constents.findIndex(content => content.id === file.id),
|
||||
contents: constents,
|
||||
initiallyRevealedContentIds: [file.id],
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<details v-if="note.files && note.files.length > 0">
|
||||
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
|
||||
<MkMediaList :mediaList="note.files"/>
|
||||
<MkMediaList :mediaList="note.files" :user="note.user"/>
|
||||
</details>
|
||||
<details v-if="note.poll">
|
||||
<summary>{{ i18n.ts.poll }}</summary>
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { UploaderItem } from '@/composables/use-uploader.js';
|
||||
import { getUploadName } from '@/composables/use-uploader.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import * as os from '@/os.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
@@ -101,12 +102,12 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
|
||||
}
|
||||
|
||||
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
|
||||
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
|
||||
if (isPreviewable(item.file.type)) {
|
||||
const contents = props.items
|
||||
.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video'))
|
||||
.filter(item => isPreviewable(item.file.type))
|
||||
.map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
|
||||
type: getType(item.file.type),
|
||||
url: item.objectUrl,
|
||||
thumbnailUrl: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const videoEl = inject(DI.mkLightboxItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
|
||||
return bufferedEnd.value / videoEl.value.duration;
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.value?.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.value?.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.value?.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
|
||||
function init() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
isReady.value = true;
|
||||
abortController = new AbortController();
|
||||
|
||||
function updateMediaTick() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
try {
|
||||
bufferedEnd.value = videoEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||
|
||||
if (videoEl.value.loop !== loop.value) {
|
||||
loop.value = videoEl.value.loop;
|
||||
}
|
||||
|
||||
if (videoEl.value.paused !== !isPlaying.value) {
|
||||
isPlaying.value = !videoEl.value.paused;
|
||||
}
|
||||
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.value.addEventListener('waiting', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('playing', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
videoEl.value.addEventListener('durationchange', () => {
|
||||
durationMs.value = videoEl.value!.duration * 1000;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.volume = volume.value;
|
||||
hasAudio(videoEl.value).then(had => {
|
||||
if (!had) {
|
||||
videoEl.value!.loop = videoEl.value!.muted = true;
|
||||
videoEl.value!.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(videoEl, () => {
|
||||
if (abortController != null) {
|
||||
abortController.abort();
|
||||
}
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isPlaying,
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
|
||||
<MkMediaList v-if="image" :mediaList="[image]" :user="page.user" :class="$style.mediaList"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { notePage } from '@/filters/note.js';
|
||||
import type { DI as DIType } from '@/di.js';
|
||||
import type { ExtractInjectedType } from '@/types/misc.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { WordMuteResult } from '@/utility/check-word-mute.js';
|
||||
|
||||
export interface UseNoteProps {
|
||||
note: Misskey.entities.Note;
|
||||
@@ -66,7 +67,7 @@ export function checkNoteWordMute(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
mutedWords: Array<string | string[]> | null,
|
||||
): Array<string | string[]> | false {
|
||||
): WordMuteResult {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, user, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
@@ -114,6 +115,11 @@ export function useNote(
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
|
||||
// nullになった場合(非表示)はこれ以上やることがないのでループを抜ける
|
||||
if (result == null) {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -121,7 +127,7 @@ export function useNote(
|
||||
if (result == null) {
|
||||
hideByPlugin = true;
|
||||
} else {
|
||||
rawNote = result as Misskey.entities.Note;
|
||||
rawNote = result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
export type UploaderFeatures = {
|
||||
@@ -236,16 +237,16 @@ export function useUploader(options: {
|
||||
},
|
||||
});
|
||||
|
||||
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
|
||||
if (isPreviewable(item.file.type)) {
|
||||
menu.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const contents = items.value
|
||||
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
|
||||
.filter(item => isPreviewable(item.file.type))
|
||||
.map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: item.file.type.startsWith('video/') ? 'video' : 'image',
|
||||
type: getType(item.file.type),
|
||||
url: item.objectUrl,
|
||||
thumbnail: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
|
||||
@@ -19,5 +19,5 @@ export const DI = {
|
||||
inModal: Symbol() as InjectionKey<boolean>,
|
||||
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
|
||||
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
|
||||
mkLightboxItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
|
||||
mkLightboxItemMediaEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | HTMLAudioElement | null>>,
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = defineProps<{
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
|
||||
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
|
||||
condFormula: any;
|
||||
policies: any;
|
||||
};
|
||||
@@ -62,7 +62,6 @@ if (props.id) {
|
||||
target: 'manual',
|
||||
condFormula: { id: genId(), type: 'isRemote' },
|
||||
isPublic: false,
|
||||
isPublicDisplayRequired: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
|
||||
@@ -62,12 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="role.isPublicDisplayRequired" :disabled="!role.isPublic" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.isPublicDisplayRequired }}</template>
|
||||
<template #caption>{{ i18n.ts._role.descriptionOfIsPublicDisplayRequired }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="role.asBadge" :disabled="!role.isPublic" :readonly="readonly">
|
||||
<MkSwitch v-model="role.asBadge" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.asBadge }}</template>
|
||||
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
|
||||
</MkSwitch>
|
||||
@@ -115,7 +110,7 @@ import { instance } from '@/instance.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import type { PolicyMeta } from './roles.policy-editor.vue';
|
||||
|
||||
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
|
||||
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
|
||||
id?: Misskey.entities.Role['id'] | null;
|
||||
condFormula: any;
|
||||
policies: any;
|
||||
@@ -205,7 +200,6 @@ const save = throttle(100, () => {
|
||||
isAdministrator: role.value.isAdministrator,
|
||||
isModerator: role.value.isModerator,
|
||||
isPublic: role.value.isPublic,
|
||||
isPublicDisplayRequired: role.value.isPublicDisplayRequired,
|
||||
isExplorable: role.value.isExplorable,
|
||||
asBadge: role.value.asBadge,
|
||||
canEditMembersByModerator: role.value.canEditMembersByModerator,
|
||||
|
||||
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:enableEmojiMenu="true"
|
||||
:enableEmojiMenuReaction="true"
|
||||
/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]" :user="message.fromUser"/>
|
||||
</MkFukidashi>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
||||
<div :class="$style.footer">
|
||||
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else-if="file" class="_gaps">
|
||||
<div :class="$style.filePreviewRoot">
|
||||
<MkMediaList :mediaList="[file]"></MkMediaList>
|
||||
<MkMediaList :mediaList="[file]" :user="file.user"></MkMediaList>
|
||||
</div>
|
||||
<div :class="$style.fileQuickActionsRoot">
|
||||
<button class="_button" :class="$style.fileNameEditBtn" @click="rename()">
|
||||
|
||||
@@ -49,6 +49,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['roles']">
|
||||
<MkFolder>
|
||||
<template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['account', 'move', 'migration']">
|
||||
<MkFolder>
|
||||
<template #icon><SearchIcon><i class="ti ti-plane"></i></SearchIcon></template>
|
||||
@@ -115,15 +126,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="_gaps_s">
|
||||
<!-- ページ移動したのでリンクを残してある。そのうち消しても良いかも -->
|
||||
<FormLink to="/settings/roles">
|
||||
<template #icon><i class="ti ti-badges"></i></template>
|
||||
{{ i18n.ts.rolesAssignedToMe }}
|
||||
</FormLink>
|
||||
|
||||
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
|
||||
</div>
|
||||
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -162,6 +166,7 @@ import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { signout } from '@/signout.js';
|
||||
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
||||
import { suggestReload } from '@/utility/reload-suggest.js';
|
||||
|
||||
@@ -72,11 +72,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSwitch>
|
||||
</SearchMarker>
|
||||
|
||||
<FormLink to="/settings/roles">
|
||||
<template #icon><i class="ti ti-badges"></i></template>
|
||||
<SearchLabel>{{ i18n.ts._roleDisplay.title }}</SearchLabel>
|
||||
</FormLink>
|
||||
|
||||
<SearchMarker :keywords="['chat']">
|
||||
<FormSection>
|
||||
<template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template>
|
||||
@@ -228,7 +223,6 @@ import { instance } from '@/instance.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import { formatDateTimeString } from '@/utility/format-time-string.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<SearchMarker path="/settings/roles" :label="i18n.ts.roles" :keywords="['role', 'badge']" icon="ti ti-badges">
|
||||
<div class="_gaps_m">
|
||||
<MkFeatureBanner icon="/fluent-emoji/1f3f7.png" color="#ffbf00">
|
||||
<SearchText>{{ i18n.ts._roleDisplay.description }}</SearchText>
|
||||
</MkFeatureBanner>
|
||||
<div class="_gaps_s">
|
||||
<MkResult v-if="roleDisplayRoles.length === 0" type="empty"/>
|
||||
<div v-for="role in roleDisplayRoles" :key="role.id" class="_panel _gaps" :class="$style.roleItem">
|
||||
<MkRolePreview :role="role" :forModeration="false"/>
|
||||
<MkSwitch
|
||||
:modelValue="isRoleDisplayShown(role)"
|
||||
:disabled="role.isPublicDisplayRequired"
|
||||
@update:modelValue="value => updateRoleDisplay(role, value)"
|
||||
>
|
||||
<template #label>{{ i18n.ts._roleDisplay.displayToggle }}</template>
|
||||
<template v-if="role.isPublicDisplayRequired" #caption>{{ i18n.ts._roleDisplay.alwaysShownByAdmin }}</template>
|
||||
<template v-else-if="role.isExplorable" #caption>
|
||||
<i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <I18n :src="i18n.ts._roleDisplay.roleExplorableAlert">
|
||||
<template #link>
|
||||
<MkA class="_link" :to="`/roles/${role.id}`">{{ i18n.ts.explore }}</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { updateCurrentAccount } from '@/accounts.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const hiddenRoleIds = ref([...getHiddenRoleIds($i)]);
|
||||
const roleDisplayRoles = computed(() => $i.roles);
|
||||
|
||||
function getHiddenRoleIds(user: { hiddenRoleIds?: string[] }): string[] {
|
||||
return user.hiddenRoleIds ?? [];
|
||||
}
|
||||
|
||||
function isRoleDisplayShown(role: Misskey.entities.RoleLite): boolean {
|
||||
return role.isPublicDisplayRequired === true || !hiddenRoleIds.value.includes(role.id);
|
||||
}
|
||||
|
||||
async function updateRoleDisplay(role: Misskey.entities.RoleLite, visible: boolean) {
|
||||
if (role.isPublicDisplayRequired === true) return;
|
||||
|
||||
const nextHiddenRoleIds = new Set(hiddenRoleIds.value);
|
||||
if (visible) {
|
||||
nextHiddenRoleIds.delete(role.id);
|
||||
} else {
|
||||
nextHiddenRoleIds.add(role.id);
|
||||
}
|
||||
|
||||
const nextIds = roleDisplayRoles.value
|
||||
.filter(role => role.isPublicDisplayRequired !== true && nextHiddenRoleIds.has(role.id))
|
||||
.map(role => role.id);
|
||||
|
||||
const updated = await misskeyApi('i/update', {
|
||||
hiddenRoleIds: nextIds,
|
||||
});
|
||||
|
||||
updateCurrentAccount(updated);
|
||||
hiddenRoleIds.value = [...getHiddenRoleIds(updated)];
|
||||
}
|
||||
|
||||
definePage(() => ({
|
||||
title: i18n.ts.roles,
|
||||
icon: 'ti ti-badges',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.roleItem {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
|
||||
</MkFukidashi>
|
||||
</div>
|
||||
<div v-if="visibleProfileRoles.length > 0" class="roles">
|
||||
<span v-for="role in visibleProfileRoles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
|
||||
<div v-if="user.roles.length > 0" class="roles">
|
||||
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
|
||||
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
|
||||
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
|
||||
{{ role.name }}
|
||||
@@ -222,14 +222,6 @@ const emit = defineEmits<{
|
||||
const router = useRouter();
|
||||
|
||||
const user = ref(props.user);
|
||||
const visibleProfileRoles = computed(() => {
|
||||
const roles = user.value.roles;
|
||||
if ($i == null || $i.id !== user.value.id) return roles;
|
||||
|
||||
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
|
||||
const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []);
|
||||
return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id));
|
||||
});
|
||||
const narrow = ref<null | boolean>(null);
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const bannerEl = useTemplateRef('bannerEl');
|
||||
|
||||
@@ -88,10 +88,6 @@ export const ROUTE_DEF = [{
|
||||
path: '/privacy',
|
||||
name: 'privacy',
|
||||
component: page(() => import('@/pages/settings/privacy.vue')),
|
||||
}, {
|
||||
path: '/roles',
|
||||
name: 'roles',
|
||||
component: page(() => import('@/pages/settings/roles.vue')),
|
||||
}, {
|
||||
path: '/emoji-palette',
|
||||
name: 'emoji-palette',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type MediaComponentExposes = {
|
||||
isRevealed: () => boolean;
|
||||
};
|
||||
@@ -4,35 +4,66 @@
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
|
||||
export type WordMuteResult = Array<string | string[]> | false;
|
||||
|
||||
// ミュートワードの判定はタイムラインのノートごとに繰り返し実行されるため、
|
||||
// フィルタ単位の前処理(正規表現のコンパイル・空キーワードの除去)をキャッシュする
|
||||
// keywordsCacheはWeakMapによりフィルタ配列がGCされるとキャッシュも破棄される
|
||||
const regexCache = new Map<string, RegExp | null>();
|
||||
const keywordsCache = new WeakMap<string[], string[]>();
|
||||
|
||||
function getCompiledRegex(filter: string): RegExp | null {
|
||||
let regex = regexCache.get(filter);
|
||||
if (regex === undefined) {
|
||||
const parts = filter.match(/^\/(.+)\/(.*)$/);
|
||||
if (parts == null) {
|
||||
// This should never happen due to input sanitisation.
|
||||
regex = null;
|
||||
} else {
|
||||
try {
|
||||
regex = new RegExp(parts[1], parts[2]);
|
||||
} catch (_) {
|
||||
// This should never happen due to input sanitisation.
|
||||
regex = null;
|
||||
}
|
||||
}
|
||||
regexCache.set(filter, regex);
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
function getCleanedKeywords(filter: string[]): string[] {
|
||||
let keywords = keywordsCache.get(filter);
|
||||
if (keywords === undefined) {
|
||||
keywords = filter.filter(keyword => keyword !== '');
|
||||
keywordsCache.set(filter, keywords);
|
||||
}
|
||||
return keywords;
|
||||
}
|
||||
|
||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): WordMuteResult {
|
||||
// 自分自身
|
||||
if (me && (note.userId === me.id)) return false;
|
||||
|
||||
if (mutedWords.length > 0) {
|
||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
||||
if (mutedWords.length === 0) return false;
|
||||
|
||||
if (text === '') return false;
|
||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
||||
|
||||
if (text !== '') {
|
||||
const matched = mutedWords.filter(filter => {
|
||||
if (Array.isArray(filter)) {
|
||||
// Clean up
|
||||
const filteredFilter = filter.filter(keyword => keyword !== '');
|
||||
if (filteredFilter.length === 0) return false;
|
||||
const keywords = getCleanedKeywords(filter);
|
||||
if (keywords.length === 0) return false;
|
||||
|
||||
return filteredFilter.every(keyword => text.includes(keyword));
|
||||
return keywords.every(keyword => text.includes(keyword));
|
||||
} else {
|
||||
// represents RegExp
|
||||
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||
const regex = getCompiledRegex(filter);
|
||||
if (regex == null) return false;
|
||||
|
||||
// This should never happen due to input sanitisation.
|
||||
if (!regexp) return false;
|
||||
|
||||
try {
|
||||
return new RegExp(regexp[1], regexp[2]).test(text);
|
||||
} catch (_) {
|
||||
// This should never happen due to input sanitisation.
|
||||
return false;
|
||||
}
|
||||
// gフラグ等が付いているとtestでlastIndexが進み、共有インスタンスでは結果が変わってしまうためリセットする
|
||||
regex.lastIndex = 0;
|
||||
return regex.test(text);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -303,12 +303,18 @@ export function getNoteMenu(props: {
|
||||
|
||||
async function translate(): Promise<void> {
|
||||
if (props.translation.value != null) return;
|
||||
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
|
||||
|
||||
let text = appearNote.text ?? '';
|
||||
if (appearNote.cw != null) {
|
||||
text = `${appearNote.cw}\n-----\n${text}`;
|
||||
}
|
||||
|
||||
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && text.trim() !== '') {
|
||||
props.translating.value = true;
|
||||
try {
|
||||
// @ts-expect-error 実験的なAPIなので型定義がない
|
||||
const detector = await LanguageDetector.create();
|
||||
const langResult = await detector.detect(appearNote.text);
|
||||
const langResult = await detector.detect(text);
|
||||
let localStorageLang = miLocalStorage.getItem('lang');
|
||||
if (localStorageLang != null) {
|
||||
localStorageLang = localStorageLang.split('-')[0];
|
||||
@@ -318,7 +324,7 @@ export function getNoteMenu(props: {
|
||||
if (langResult[0]?.detectedLanguage === localStorageLang || langResult[0]?.detectedLanguage === navigator.language) {
|
||||
props.translation.value = {
|
||||
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
|
||||
text: appearNote.text,
|
||||
text: text,
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +334,7 @@ export function getNoteMenu(props: {
|
||||
sourceLanguage: langResult[0]?.detectedLanguage,
|
||||
targetLanguage: localStorageLang ?? navigator.language,
|
||||
});
|
||||
const translated = await translator.translate(appearNote.text);
|
||||
const translated = await translator.translate(text);
|
||||
props.translation.value = {
|
||||
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
|
||||
text: translated,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
|
||||
export function isPreviewable(mime: string): boolean {
|
||||
if (mime === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
const strippedMime = mime.split(';')[0].trim();
|
||||
return (strippedMime.startsWith('image') || strippedMime.startsWith('video') || strippedMime.startsWith('audio')) && FILE_TYPE_BROWSERSAFE.includes(strippedMime);
|
||||
}
|
||||
|
||||
export function getType(mime: string): 'image' | 'video' | 'audio' {
|
||||
if (mime.startsWith('image')) return 'image';
|
||||
if (mime.startsWith('video')) return 'video';
|
||||
if (mime.startsWith('audio')) return 'audio';
|
||||
throw new Error(`Unsupported file type: ${mime}`);
|
||||
}
|
||||
@@ -4172,6 +4172,10 @@ export interface Locale extends ILocale {
|
||||
* 読み込めません
|
||||
*/
|
||||
"cannotLoad": string;
|
||||
/**
|
||||
* プレビューできません
|
||||
*/
|
||||
"cannotPreview": string;
|
||||
/**
|
||||
* プロフィール表示回数
|
||||
*/
|
||||
@@ -8030,14 +8034,6 @@ export interface Locale extends ILocale {
|
||||
* ユーザーのプロフィールでこのロールが表示されます。
|
||||
*/
|
||||
"descriptionOfIsPublic": string;
|
||||
/**
|
||||
* 非表示を許可しない(常に表示)
|
||||
*/
|
||||
"isPublicDisplayRequired": string;
|
||||
/**
|
||||
* 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。
|
||||
*/
|
||||
"descriptionOfIsPublicDisplayRequired": string;
|
||||
/**
|
||||
* オプション
|
||||
*/
|
||||
@@ -12191,28 +12187,6 @@ export interface Locale extends ILocale {
|
||||
"notUnique": string;
|
||||
};
|
||||
};
|
||||
"_roleDisplay": {
|
||||
/**
|
||||
* ロールの表示/非表示
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* 自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。
|
||||
*/
|
||||
"description": string;
|
||||
/**
|
||||
* このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。
|
||||
*/
|
||||
"roleExplorableAlert": ParameterizedString<"link">;
|
||||
/**
|
||||
* ロール/ロールバッジを表示する
|
||||
*/
|
||||
"displayToggle": string;
|
||||
/**
|
||||
* 管理者の設定により非表示にすることはできません。
|
||||
*/
|
||||
"alwaysShownByAdmin": string;
|
||||
};
|
||||
"_roleSelectDialog": {
|
||||
/**
|
||||
* 選択されていません
|
||||
|
||||
@@ -4061,8 +4061,6 @@ export type components = {
|
||||
/** @enum {string} */
|
||||
onlineStatus: 'unknown' | 'online' | 'active' | 'offline';
|
||||
badgeRoles?: {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
displayOrder: number;
|
||||
@@ -4153,7 +4151,6 @@ export type components = {
|
||||
preventAiLearning: boolean;
|
||||
isExplorable: boolean;
|
||||
isDeleted: boolean;
|
||||
hiddenRoleIds: string[];
|
||||
/** @enum {string} */
|
||||
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
|
||||
hideOnlineStatus: boolean;
|
||||
@@ -5325,12 +5322,6 @@ export type components = {
|
||||
isModerator: boolean;
|
||||
/** @example false */
|
||||
isAdministrator: boolean;
|
||||
/** @example false */
|
||||
asBadge: boolean;
|
||||
/** @example false */
|
||||
isPublicDisplayRequired: boolean;
|
||||
/** @example false */
|
||||
isExplorable: boolean;
|
||||
/** @example 0 */
|
||||
displayOrder: number;
|
||||
};
|
||||
@@ -5345,8 +5336,6 @@ export type components = {
|
||||
/** @example false */
|
||||
isPublic: boolean;
|
||||
/** @example false */
|
||||
isPublicDisplayRequired: boolean;
|
||||
/** @example false */
|
||||
isExplorable: boolean;
|
||||
/** @example false */
|
||||
asBadge: boolean;
|
||||
@@ -11052,8 +11041,6 @@ export interface operations {
|
||||
target: 'manual' | 'conditional';
|
||||
condFormula: Record<string, never>;
|
||||
isPublic: boolean;
|
||||
/** @default false */
|
||||
isPublicDisplayRequired?: boolean;
|
||||
isModerator: boolean;
|
||||
isAdministrator: boolean;
|
||||
/** @default false */
|
||||
@@ -11389,7 +11376,6 @@ export interface operations {
|
||||
target?: 'manual' | 'conditional';
|
||||
condFormula?: Record<string, never>;
|
||||
isPublic?: boolean;
|
||||
isPublicDisplayRequired?: boolean;
|
||||
isModerator?: boolean;
|
||||
isAdministrator?: boolean;
|
||||
isExplorable?: boolean;
|
||||
@@ -27912,7 +27898,6 @@ export interface operations {
|
||||
followersVisibility?: 'public' | 'followers' | 'private';
|
||||
/** @enum {string} */
|
||||
chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
|
||||
hiddenRoleIds?: string[];
|
||||
/** Format: misskey:id */
|
||||
pinnedPageId?: string | null;
|
||||
mutedWords?: (string[] | string)[];
|
||||
|
||||
Reference in New Issue
Block a user