diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 9d5e9ec62..94f6b2be9 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -30,12 +30,25 @@ info = Information ## Input Placeholders search-placeholder = Search... +search-for-streamers = Search for streamers... message-input = Enter your message... ## Authentication & Access please-log-in-to-access-this-page = Please log in to access this page go-to-settings = Go to Settings go-back = Go Back +sign-in = Sign In +log-in = Log in +go-home = Go home +try-again = Try again +completing-sign-in = Completing sign-in… +already-logged-in = You're already logged in. +signed-in-as = Signed in as @{ $handle } +signed-in-as-code = Signed in as { $handle } +sign-in-description = Sign in with your Bluesky handle. You'll be redirected to your PDS to authorize this app. +handle-label = Handle +redirecting = Redirecting… +sign-in-failed = Sign-in failed ## Demo and Testing welcome-user = Welcome, { $username }! @@ -73,6 +86,116 @@ pds-selector-info = Each host has their own policies and reliability standards. pds-selector-read-policies = Read { $label }'s Terms of Service and Privacy Policy before continuing. pds-selector-handle-policy-checkbox = I have read and agree to the handle policy +## Header +profile = Profile +more-results = More results + +## Sidebar Navigation +nav-home = Home +nav-videos = Videos +nav-following = Following +nav-account = Account + +## Chat +chat-no-messages = No messages yet +chat-scroll-to-top = Scroll to top +chat-scroll-to-bottom = Scroll to bottom +chat-reply-to-message = Reply to message +chat-pin-message = Pin message +chat-unpin-message = Unpin message +chat-dismiss-pinned = Dismiss pinned message +chat-view-profile-bluesky = View profile on Bluesky +chat-send-message = Send a message +chat-replying-to = Replying to { $handle } +chat-insert-emoji = Insert emoji +chat-send-button = Chat +chat-failed-send = Failed to send message +chat-log-in-to = Log in to chat +chat-pop-out = Pop out chat +chat-close = Close chat +streamer-fallback = Streamer + +## Badges +badge-moderator = Moderator +badge-streamer = Streamer +badge-vip = VIP +badge-bot = Bot +badge-event = Event +badge-fallback = Badge +badge-issued-by-streamplace = Issued by Streamplace +badge-issued-by = Issued by { $issuer } +badge-issued = Issued +badge-self-labeled = Self-labeled + +## Stream Info +activity-events = Events +activity-just-chatting = Just Chatting +activity-music = Music +activity-art = Art +activity-software-dev = Software Dev +activity-cooking = Cooking +activity-miniatures = Miniatures +activity-makers-crafting = Makers & Crafting +activity-fitness = Fitness +activity-sports = Sports +watching-count = { $count } watching +follow = Follow +share-copy = Copy +share-copy-link = Copy Link +share-copy-embed-code = Copy Embed Code +share-copy-embed-url = Copy Embed URL + +## Video Player +live-badge = Live +reconnecting = Reconnecting… +stream-may-resume = The stream may resume shortly. +stream-offline = Stream offline +user-not-streaming = { $user } is not currently streaming +stream-is-offline-title = Stream is offline +user-not-streaming-check-back = { $user } is not currently streaming. Check back later. +back-to-home = Back to home +download-video = Download video +untitled = Untitled +default-stream-title = A livestream! + +## Notifications +teleporting-in = Teleporting in + +## Video Card +views-count = { $count -> + [one] { $count } view + *[other] { $count } views +} +likes-count = { $count -> + [one] { $count } like + *[other] { $count } likes +} + +## Home Page +could-not-load-streams = Could not load streams. You might be offline. +live-now-count = { $count -> + [one] { $count } person live now + *[other] { $count } people live now +} +no-one-streaming = No one is streaming right now +check-back-later = Check back later? +hero-title = The video layer for everything. +hero-description = Streamplace is a streaming platform built on the AT Protocol. +learn-more = Learn more + +## Videos Page +videos-title = Videos +no-videos-yet = No videos yet. +could-not-load-videos = Couldn't load videos: { $error } + +## Search Page +no-results-for = No results for "{ $query }" +find-streamers = Find streamers by name or handle + +## Error Boundary +something-went-wrong = Something went wrong +unexpected-error = An unexpected error occurred. + ## Login login-show-live-on-bluesky = Show when I'm live on Bluesky login-show-live-on-bluesky-description = Gives your Bluesky avatar the red LIVE ring while you stream and lets Streamplace post announcements for you. Uncheck to sign in without granting any access to your Bluesky account. diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index d3c4f3794..356215f1a 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -319,3 +319,13 @@ issue-badges-recipient-did = Recipient DID issue-badges-recipient-did-placeholder = did:plc:... issue-badges-tap-to-issue = Tap to issue issue-badges-your-definitions = Your Badge Definitions +chat-profile = Chat Profile +chat-profile-login-required = Please log in to edit your chat profile. +chat-profile-saved = Chat profile saved +chat-profile-save-failed = Failed to save chat profile +chat-profile-name-color = Name Color +chat-profile-name-color-description = Choose the color of your name in chat. +chat-profile-preview-message = Hello, world! +chat-profile-self-labels = Self Labels +chat-profile-label-bot = Bot +chat-profile-label-bot-description = Mark your chat messages as coming from a bot. diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json index 411845c83..b8d340826 100644 --- a/js/i18n/public/locales/en-US/common.json +++ b/js/i18n/public/locales/en-US/common.json @@ -22,10 +22,23 @@ "warning": "Warning", "info": "Information", "search-placeholder": "Search...", + "search-for-streamers": "Search for streamers...", "message-input": "Enter your message...", "please-log-in-to-access-this-page": "Please log in to access this page", "go-to-settings": "Go to Settings", "go-back": "Go Back", + "sign-in": "Sign In", + "log-in": "Log in", + "go-home": "Go home", + "try-again": "Try again", + "completing-sign-in": "Completing sign-in…", + "already-logged-in": "You're already logged in.", + "signed-in-as": "Signed in as @{$handle}", + "signed-in-as-code": "Signed in as {$handle}", + "sign-in-description": "Sign in with your Bluesky handle. You'll be redirected to your PDS to authorize this app.", + "handle-label": "Handle", + "redirecting": "Redirecting…", + "sign-in-failed": "Sign-in failed", "welcome-user": "Welcome, {$username}!", "notification-count": "{$count ->\n [0] No notifications\n [1] One notification\n *[other] {$count} notifications\n }", "user-offline": "user is offline", @@ -42,5 +55,82 @@ "pds-selector-learn-more": "Learn more about self-hosting", "pds-selector-info": "Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose.", "pds-selector-read-policies": "Read {$label}'s Terms of Service and Privacy Policy before continuing.", - "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy" + "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy", + "profile": "Profile", + "more-results": "More results", + "nav-home": "Home", + "nav-videos": "Videos", + "nav-following": "Following", + "nav-account": "Account", + "chat-no-messages": "No messages yet", + "chat-scroll-to-top": "Scroll to top", + "chat-scroll-to-bottom": "Scroll to bottom", + "chat-reply-to-message": "Reply to message", + "chat-pin-message": "Pin message", + "chat-unpin-message": "Unpin message", + "chat-dismiss-pinned": "Dismiss pinned message", + "chat-view-profile-bluesky": "View profile on Bluesky", + "chat-send-message": "Send a message", + "chat-replying-to": "Replying to {$handle}", + "chat-insert-emoji": "Insert emoji", + "chat-send-button": "Chat", + "chat-failed-send": "Failed to send message", + "chat-log-in-to": "Log in to chat", + "chat-pop-out": "Pop out chat", + "chat-close": "Close chat", + "streamer-fallback": "Streamer", + "badge-moderator": "Moderator", + "badge-streamer": "Streamer", + "badge-vip": "VIP", + "badge-bot": "Bot", + "badge-event": "Event", + "badge-fallback": "Badge", + "badge-issued-by-streamplace": "Issued by Streamplace", + "badge-issued-by": "Issued by {$issuer}", + "badge-issued": "Issued", + "badge-self-labeled": "Self-labeled", + "activity-events": "Events", + "activity-just-chatting": "Just Chatting", + "activity-music": "Music", + "activity-art": "Art", + "activity-software-dev": "Software Dev", + "activity-cooking": "Cooking", + "activity-miniatures": "Miniatures", + "activity-makers-crafting": "Makers & Crafting", + "activity-fitness": "Fitness", + "activity-sports": "Sports", + "watching-count": "{$count} watching", + "follow": "Follow", + "share-copy": "Copy", + "share-copy-link": "Copy Link", + "share-copy-embed-code": "Copy Embed Code", + "share-copy-embed-url": "Copy Embed URL", + "live-badge": "Live", + "reconnecting": "Reconnecting…", + "stream-may-resume": "The stream may resume shortly.", + "stream-offline": "Stream offline", + "user-not-streaming": "{$user} is not currently streaming", + "stream-is-offline-title": "Stream is offline", + "user-not-streaming-check-back": "{$user} is not currently streaming. Check back later.", + "back-to-home": "Back to home", + "download-video": "Download video", + "untitled": "Untitled", + "default-stream-title": "A livestream!", + "teleporting-in": "Teleporting in", + "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", + "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", + "could-not-load-streams": "Could not load streams. You might be offline.", + "live-now-count": "{$count ->\n [one] {$count} person live now\n *[other] {$count} people live now\n }", + "no-one-streaming": "No one is streaming right now", + "check-back-later": "Check back later?", + "hero-title": "The video layer for everything.", + "hero-description": "Streamplace is a streaming platform built on the AT Protocol.", + "learn-more": "Learn more", + "videos-title": "Videos", + "no-videos-yet": "No videos yet.", + "could-not-load-videos": "Couldn't load videos: {$error}", + "no-results-for": "No results for \"{$query}\"", + "find-streamers": "Find streamers by name or handle", + "something-went-wrong": "Something went wrong", + "unexpected-error": "An unexpected error occurred." } diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 24fdfb7e0..2a62373bf 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -237,5 +237,15 @@ "issue-badges-recipient-did": "Recipient DID", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "Tap to issue", - "issue-badges-your-definitions": "Your Badge Definitions" + "issue-badges-your-definitions": "Your Badge Definitions", + "chat-profile": "Chat Profile", + "chat-profile-login-required": "Please log in to edit your chat profile.", + "chat-profile-saved": "Chat profile saved", + "chat-profile-save-failed": "Failed to save chat profile", + "chat-profile-name-color": "Name Color", + "chat-profile-name-color-description": "Choose the color of your name in chat.", + "chat-profile-preview-message": "Hello, world!", + "chat-profile-self-labels": "Self Labels", + "chat-profile-label-bot": "Bot", + "chat-profile-label-bot-description": "Mark your chat messages as coming from a bot." } diff --git a/js/i18n/public/locales/es-ES/settings.json b/js/i18n/public/locales/es-ES/settings.json index 128c6029c..de541a694 100644 --- a/js/i18n/public/locales/es-ES/settings.json +++ b/js/i18n/public/locales/es-ES/settings.json @@ -242,5 +242,15 @@ "issue-badges-recipient-did": "DID del destinatario", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "Toca para emitir", - "issue-badges-your-definitions": "Tus definiciones de insignias" + "issue-badges-your-definitions": "Tus definiciones de insignias", + "chat-profile": "Perfil de chat", + "chat-profile-login-required": "Inicia sesión para editar tu perfil de chat.", + "chat-profile-saved": "Perfil de chat guardado", + "chat-profile-save-failed": "Error al guardar el perfil de chat", + "chat-profile-name-color": "Color de nombre", + "chat-profile-name-color-description": "Elige el color de tu nombre en el chat.", + "chat-profile-preview-message": "¡Hola, mundo!", + "chat-profile-self-labels": "Etiquetas propias", + "chat-profile-label-bot": "Bot", + "chat-profile-label-bot-description": "Marca tus mensajes de chat como provenientes de un bot." } diff --git a/js/i18n/public/locales/fr-FR/settings.json b/js/i18n/public/locales/fr-FR/settings.json index 693dae2b9..9aa7b772c 100644 --- a/js/i18n/public/locales/fr-FR/settings.json +++ b/js/i18n/public/locales/fr-FR/settings.json @@ -242,5 +242,15 @@ "issue-badges-recipient-did": "DID du destinataire", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "Appuyer pour délivrer", - "issue-badges-your-definitions": "Vos définitions de badges" + "issue-badges-your-definitions": "Vos définitions de badges", + "chat-profile": "Profil de chat", + "chat-profile-login-required": "Connectez-vous pour modifier votre profil de chat.", + "chat-profile-saved": "Profil de chat enregistré", + "chat-profile-save-failed": "Échec de l'enregistrement du profil de chat", + "chat-profile-name-color": "Couleur du nom", + "chat-profile-name-color-description": "Choisissez la couleur de votre nom dans le chat.", + "chat-profile-preview-message": "Bonjour le monde !", + "chat-profile-self-labels": "Étiquettes personnelles", + "chat-profile-label-bot": "Bot", + "chat-profile-label-bot-description": "Marquez vos messages de chat comme provenant d'un bot." } diff --git a/js/i18n/public/locales/pt-BR/settings.json b/js/i18n/public/locales/pt-BR/settings.json index e9c6b7066..cf57e26e2 100644 --- a/js/i18n/public/locales/pt-BR/settings.json +++ b/js/i18n/public/locales/pt-BR/settings.json @@ -242,5 +242,15 @@ "issue-badges-recipient-did": "DID do destinatário", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "Toque para emitir", - "issue-badges-your-definitions": "Suas definições de emblemas" + "issue-badges-your-definitions": "Suas definições de emblemas", + "chat-profile": "Perfil de chat", + "chat-profile-login-required": "Faça login para editar seu perfil de chat.", + "chat-profile-saved": "Perfil de chat salvo", + "chat-profile-save-failed": "Falha ao salvar perfil de chat", + "chat-profile-name-color": "Cor do nome", + "chat-profile-name-color-description": "Escolha a cor do seu nome no chat.", + "chat-profile-preview-message": "Olá, mundo!", + "chat-profile-self-labels": "Rótulos pessoais", + "chat-profile-label-bot": "Bot", + "chat-profile-label-bot-description": "Marque suas mensagens de chat como provenientes de um bot." } diff --git a/js/i18n/public/locales/ro-RO/settings.json b/js/i18n/public/locales/ro-RO/settings.json index 0d25901dd..609232af8 100644 --- a/js/i18n/public/locales/ro-RO/settings.json +++ b/js/i18n/public/locales/ro-RO/settings.json @@ -202,5 +202,15 @@ "issue-badges-recipient-did": "DID destinatar", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "Atingeți pentru a emite", - "issue-badges-your-definitions": "Definițiile dvs. de insigne" + "issue-badges-your-definitions": "Definițiile dvs. de insigne", + "chat-profile": "Profil de chat", + "chat-profile-login-required": "Conectați-vă pentru a vă edita profilul de chat.", + "chat-profile-saved": "Profilul de chat a fost salvat", + "chat-profile-save-failed": "Nu s-a putut salva profilul de chat", + "chat-profile-name-color": "Culoarea numelui", + "chat-profile-name-color-description": "Alegeți culoarea numelui dvs. în chat.", + "chat-profile-preview-message": "Salut, lume!", + "chat-profile-self-labels": "Etichete proprii", + "chat-profile-label-bot": "Bot", + "chat-profile-label-bot-description": "Marcați mesajele de chat ca provenind de la un bot." } diff --git a/js/i18n/public/locales/zh-Hans/settings.json b/js/i18n/public/locales/zh-Hans/settings.json index cb1c7a673..1e0238654 100644 --- a/js/i18n/public/locales/zh-Hans/settings.json +++ b/js/i18n/public/locales/zh-Hans/settings.json @@ -237,5 +237,15 @@ "issue-badges-recipient-did": "收件人 DID", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "点击颁发", - "issue-badges-your-definitions": "您的徽章定义" + "issue-badges-your-definitions": "您的徽章定义", + "chat-profile": "聊天资料", + "chat-profile-login-required": "请登录以编辑您的聊天资料。", + "chat-profile-saved": "聊天资料已保存", + "chat-profile-save-failed": "保存聊天资料失败", + "chat-profile-name-color": "名称颜色", + "chat-profile-name-color-description": "选择您在聊天中的名称颜色。", + "chat-profile-preview-message": "你好,世界!", + "chat-profile-self-labels": "自我标签", + "chat-profile-label-bot": "机器人", + "chat-profile-label-bot-description": "将您的聊天消息标记为来自机器人。" } diff --git a/js/i18n/public/locales/zh-Hant/settings.json b/js/i18n/public/locales/zh-Hant/settings.json index 29fedd1e4..98ad2f3d5 100644 --- a/js/i18n/public/locales/zh-Hant/settings.json +++ b/js/i18n/public/locales/zh-Hant/settings.json @@ -242,5 +242,15 @@ "issue-badges-recipient-did": "收件者 DID", "issue-badges-recipient-did-placeholder": "did:plc:...", "issue-badges-tap-to-issue": "點擊頒發", - "issue-badges-your-definitions": "您的徽章定義" + "issue-badges-your-definitions": "您的徽章定義", + "chat-profile": "聊天資料", + "chat-profile-login-required": "請登入以編輯您的聊天資料。", + "chat-profile-saved": "聊天資料已儲存", + "chat-profile-save-failed": "儲存聊天資料失敗", + "chat-profile-name-color": "名稱顏色", + "chat-profile-name-color-description": "選擇您在聊天中的名稱顏色。", + "chat-profile-preview-message": "你好,世界!", + "chat-profile-self-labels": "自我標籤", + "chat-profile-label-bot": "機器人", + "chat-profile-label-bot-description": "將您的聊天訊息標記為來自機器人。" } diff --git a/js/web/public/locales/en-US/common.json b/js/web/public/locales/en-US/common.json new file mode 100644 index 000000000..b8d340826 --- /dev/null +++ b/js/web/public/locales/en-US/common.json @@ -0,0 +1,136 @@ +{ + "loading": "Loading...", + "error": "Error", + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "open": "Open", + "ok": "OK", + "yes": "Yes", + "no": "No", + "continue": "Continue", + "back": "Back", + "next": "Next", + "finish": "Finish", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "update": "Update", + "refresh": "Refresh", + "success": "Success", + "warning": "Warning", + "info": "Information", + "search-placeholder": "Search...", + "search-for-streamers": "Search for streamers...", + "message-input": "Enter your message...", + "please-log-in-to-access-this-page": "Please log in to access this page", + "go-to-settings": "Go to Settings", + "go-back": "Go Back", + "sign-in": "Sign In", + "log-in": "Log in", + "go-home": "Go home", + "try-again": "Try again", + "completing-sign-in": "Completing sign-in…", + "already-logged-in": "You're already logged in.", + "signed-in-as": "Signed in as @{$handle}", + "signed-in-as-code": "Signed in as {$handle}", + "sign-in-description": "Sign in with your Bluesky handle. You'll be redirected to your PDS to authorize this app.", + "handle-label": "Handle", + "redirecting": "Redirecting…", + "sign-in-failed": "Sign-in failed", + "welcome-user": "Welcome, {$username}!", + "notification-count": "{$count ->\n [0] No notifications\n [1] One notification\n *[other] {$count} notifications\n }", + "user-offline": "user is offline", + "user-offline-message": "{$source ->\n [streamer] Looks like <1>@{$handle} is offline, but they recommend checking out:\n *[default] Looks like <1>@{$handle} is offline, but we recommend checking out:\n }", + "user-offline-no-recommendations": "Looks like <1>@{$handle} is offline right now.\nCheck back later.", + "streaming-title": "streaming {$title}", + "viewer-count": "{$count ->\n [0] 0 viewers\n [1] 1 viewer\n *[other] {$count} viewers\n }", + "pds-selector-title": "New to the Atmosphere?", + "pds-selector-description": "You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark.", + "pds-selector-custom-label": "Another PDS", + "pds-selector-custom-description": "Enter your own PDS host URL", + "pds-selector-custom-url-label": "Custom PDS URL", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "Learn more about self-hosting", + "pds-selector-info": "Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose.", + "pds-selector-read-policies": "Read {$label}'s Terms of Service and Privacy Policy before continuing.", + "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy", + "profile": "Profile", + "more-results": "More results", + "nav-home": "Home", + "nav-videos": "Videos", + "nav-following": "Following", + "nav-account": "Account", + "chat-no-messages": "No messages yet", + "chat-scroll-to-top": "Scroll to top", + "chat-scroll-to-bottom": "Scroll to bottom", + "chat-reply-to-message": "Reply to message", + "chat-pin-message": "Pin message", + "chat-unpin-message": "Unpin message", + "chat-dismiss-pinned": "Dismiss pinned message", + "chat-view-profile-bluesky": "View profile on Bluesky", + "chat-send-message": "Send a message", + "chat-replying-to": "Replying to {$handle}", + "chat-insert-emoji": "Insert emoji", + "chat-send-button": "Chat", + "chat-failed-send": "Failed to send message", + "chat-log-in-to": "Log in to chat", + "chat-pop-out": "Pop out chat", + "chat-close": "Close chat", + "streamer-fallback": "Streamer", + "badge-moderator": "Moderator", + "badge-streamer": "Streamer", + "badge-vip": "VIP", + "badge-bot": "Bot", + "badge-event": "Event", + "badge-fallback": "Badge", + "badge-issued-by-streamplace": "Issued by Streamplace", + "badge-issued-by": "Issued by {$issuer}", + "badge-issued": "Issued", + "badge-self-labeled": "Self-labeled", + "activity-events": "Events", + "activity-just-chatting": "Just Chatting", + "activity-music": "Music", + "activity-art": "Art", + "activity-software-dev": "Software Dev", + "activity-cooking": "Cooking", + "activity-miniatures": "Miniatures", + "activity-makers-crafting": "Makers & Crafting", + "activity-fitness": "Fitness", + "activity-sports": "Sports", + "watching-count": "{$count} watching", + "follow": "Follow", + "share-copy": "Copy", + "share-copy-link": "Copy Link", + "share-copy-embed-code": "Copy Embed Code", + "share-copy-embed-url": "Copy Embed URL", + "live-badge": "Live", + "reconnecting": "Reconnecting…", + "stream-may-resume": "The stream may resume shortly.", + "stream-offline": "Stream offline", + "user-not-streaming": "{$user} is not currently streaming", + "stream-is-offline-title": "Stream is offline", + "user-not-streaming-check-back": "{$user} is not currently streaming. Check back later.", + "back-to-home": "Back to home", + "download-video": "Download video", + "untitled": "Untitled", + "default-stream-title": "A livestream!", + "teleporting-in": "Teleporting in", + "views-count": "{$count ->\n [one] {$count} view\n *[other] {$count} views\n }", + "likes-count": "{$count ->\n [one] {$count} like\n *[other] {$count} likes\n }", + "could-not-load-streams": "Could not load streams. You might be offline.", + "live-now-count": "{$count ->\n [one] {$count} person live now\n *[other] {$count} people live now\n }", + "no-one-streaming": "No one is streaming right now", + "check-back-later": "Check back later?", + "hero-title": "The video layer for everything.", + "hero-description": "Streamplace is a streaming platform built on the AT Protocol.", + "learn-more": "Learn more", + "videos-title": "Videos", + "no-videos-yet": "No videos yet.", + "could-not-load-videos": "Couldn't load videos: {$error}", + "no-results-for": "No results for \"{$query}\"", + "find-streamers": "Find streamers by name or handle", + "something-went-wrong": "Something went wrong", + "unexpected-error": "An unexpected error occurred." +} diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json new file mode 100644 index 000000000..24fdfb7e0 --- /dev/null +++ b/js/web/public/locales/en-US/settings.json @@ -0,0 +1,241 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Download New Update", + "check-for-updates": "Check for Updates", + "bundled-runtype": "Bundled", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Recovery Mode", + "modal-latest-version": "You are using the latest version.", + "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", + "modal-update-available-title": "Update Available", + "modal-update-available-description": "A new version of Streamplace is ready to download", + "modal-update-failed": "Update check failed. You may need to update the app through the {$store}.", + "modal-update-failed-title": "Update Failed", + "modal-update-failed-description": "Update check failed. You may need to update the app through the {$store}.", + "button-reload-app-on-update": "Apply Update (will reload app)", + "use-custom-node": "Use Custom Node", + "default-url": "Default: {$url}", + "enter-custom-node-url": "Enter custom node URL", + "save-button": "Save", + "language-selection": "Language", + "language-selection-description": "Choose your preferred language", + "input-search-languages": "Search languages...", + "help-translate": "Help us translate Streamplace", + "help-translate-description": "We're looking for volunteers to help translate Streamplace into more languages. If you're interested, please reach out to us on Discord or GitHub!", + "currently-translating": "Translations are on the way", + "currently-translating-description": "Some parts of the app may look incomplete. Thank you for your patience!", + "debug-recording-title": "Allow {$host} to record your livestream for debugging and improving the service", + "debug-recording-description": "Optional", + "manage-keys": "Manage Keys", + "settings-title": "Settings", + "error": "Error", + "about": "About", + "account": "Account", + "advanced": "Advanced", + "danmu": "Danmu", + "developer": "Developer", + "languages": "Languages", + "privacy-security": "Privacy & Security", + "streaming": "Streaming", + "cancel": "Cancel", + "create": "Create", + "delete": "Delete", + "refresh": "Refresh", + "sign-in": "Sign In", + "update": "Update", + "log-out": "Log out", + "optional": "optional", + "account-greeting": "Hey, @{$handle}.", + "edit-profile-bluesky": "Edit profile (on Bluesky)", + "change-name-color": "Change name color", + "key-management": "Key Management", + "key-manager": "Key Manager", + "your-stream-pubkeys": "Your Stream Public Keys", + "no-keys": "No keys configured", + "pubkey-description": "Public keys are paired with stream keys (used in streaming software) to sign and verify your stream", + "keys-count": "{$count ->\n [one] {$count} key\n *[other] {$count} keys\n }", + "backup": "Backup", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Automatically backup recordings to S3-compatible storage", + "backup-connection-url": "Connection URL", + "backup-connection-url-placeholder": "Must be a valid S3 URL in the format s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket", + "backup-endpoint": "Endpoint", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Bucket", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Access Key", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Secret Key", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "backup-region": "Region", + "backup-region-placeholder": "us-east-1", + "backup-test-connection": "Test Connection", + "backup-testing-connection": "Testing connection...", + "backup-connection-successful": "Connection successful", + "backup-connection-failed": "Failed to connect", + "show-password-in-url": "Show password in URL", + "show-password-in-url-description": "Display the secret key in the connection URL (if input)", + "requested-seconds-per-segment": "Seconds per segment", + "requested-seconds-per-segment-description": "Set the seconds per segment you wish to request the server use", + "recommendations": "Recommendations", + "manage-recommendations": "Manage Recommendations", + "recommendations-to-others": "Recommendations", + "recommendations-description": "Share up to 8 streamers you recommend to your viewers", + "no-recommendations-yet": "No recommendations configured yet", + "add-recommendation": "Add Recommendation", + "streamer-did": "Streamer DID", + "recommendations-count": "{$count ->\n [one] {$count} recommendation\n *[other] {$count} recommendations\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook Integrations", + "webhook-integrations-description": "Connect external services to receive real-time updates about your streams", + "create-webhook": "Create Webhook", + "edit-webhook": "Edit Webhook", + "delete-webhook": "Delete Webhook", + "no-webhooks-yet": "No webhooks configured yet", + "failed-load-webhooks": "Failed to load webhooks", + "webhook-will-no-longer-receive-events": "This webhook will no longer receive events", + "create-first-webhook-description": "Create your first webhook to start receiving stream events", + "example-captain-hook": "Captain Hook", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Activates on:", + "events": "Events", + "events-livestream": "Livestream Events", + "events-chat": "Chat Events", + "untitled-webhook": "Untitled Webhook", + "inactive": "Inactive", + "active": "Active", + "multistream": "Multistreaming", + "multistream-targets": "Multistream Targets", + "multistream-description": "Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube.", + "create-multistream-target": "Create Multistream Target", + "untitled-multistream-target": "Untitled Target", + "failed-load-multistream-targets": "Failed to load multistream targets. Please try again.", + "failed-toggle-multistream-target": "Failed to toggle multistream target. Please try again.", + "failed-delete-multistream-target": "Failed to delete multistream target. Please try again.", + "no-multistream-targets-yet": "No targets yet!", + "multistream-targets-count": "{$count ->\n [one] {$count} target\n *[other] {$count} targets\n }", + "multistream-delete-target-confirmation": "Are you sure you want to delete \"{$target}\"?", + "this-action-cannot-be-undone": "This action cannot be undone.", + "rtmp-target-name": "RTMP Target", + "rtmp-target-url": "RTMP URL", + "rtmp-target-name-placeholder": "My Multistream Target", + "multistream-create-target": "Create Target", + "multistream-edit-target": "Edit Target", + "created": "created", + "status": "status", + "debug-recording": "Debug Recording", + "danmu-enabled": "Enable Danmu", + "danmu-enabled-description": "Display live chat messages as floating comments on your screen", + "danmu-opacity": "Opacity", + "danmu-speed": "Speed", + "danmu-lane-count": "Lane Count", + "danmu-max-messages": "Max Messages", + "app-version-description": "No updates are available at this time", + "confirm-delete": "Are you sure you want to delete this?", + "action-cannot-be-undone": "This action cannot be undone", + "name-optional": "Name (optional)", + "deleting": "Deleting...", + "saving": "Saving...", + "go-to-dashboard": "Go to Dashboard", + "need-setup-live-dashboard": "Need to set up streaming first? Visit the live dashboard", + "no-languages-found": "No languages found", + "branding": "Branding", + "branding-admin": "Branding Administration", + "branding-admin-description": "Customize your Streamplace instance. Note that settings may take a few hours to propagate.", + "branding-login-required": "Please log in to manage branding", + "branding-configuration": "Configuration", + "branding-text-settings": "Text Settings", + "branding-colors": "Colors", + "branding-legal-links": "Legal Links", + "branding-images": "Images", + "branding-broadcaster-did": "Broadcaster DID", + "branding-broadcaster-did-description": "Leave empty to use server default", + "branding-site-title": "Site Title", + "branding-site-title-placeholder": "Enter new site title", + "branding-site-description": "Site Description", + "branding-site-description-placeholder": "Enter site description", + "branding-default-streamer": "Default Streamer", + "branding-default-streamer-none": "None", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Clear Default Streamer", + "branding-primary-color": "Primary Color", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Accent Color", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Main Logo", + "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG, or ICO (max 100KB)", + "branding-sidebar-bg": "Sidebar Background Image", + "branding-sidebar-bg-description": "SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.", + "branding-current": "Current: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Upload Logo", + "branding-delete-logo": "Delete Logo", + "branding-upload-favicon": "Upload Favicon", + "branding-delete-favicon": "Delete Favicon", + "branding-upload-background": "Upload Background", + "branding-delete-background": "Delete Background", + "branding-web-only": "Image uploads are only available on web.", + "refresh-branding": "Refresh branding assets", + "branding-add-legal-link": "Add Legal Link", + "branding-edit-legal-link": "Edit Legal Link", + "branding-legal-link-text-placeholder": "Link text (e.g., Privacy Policy)", + "branding-legal-link-url-placeholder": "URL (e.g., https://example.com/privacy)", + "add": "Add", + "edit": "Edit", + "branding-not-authenticated": "Please log in first", + "branding-empty-value": "Please enter a value", + "branding-update-success": "{$key} updated successfully", + "branding-upload-success": "{$key} uploaded successfully", + "branding-delete-success": "{$key} deleted successfully", + "branding-upload-failed": "Failed to upload", + "branding-delete-failed": "Failed to delete", + "branding-not-available": "File uploads are only available on web", + "node-legal-documents": "Broadcaster-specific Documents", + "backup-save": "Save backup settings", + "backup-saving": "Saving backup settings...", + "backup-secret-key-set-placeholder": "(Password already set)", + "backup-error-invalid-endpoint": "Must be a valid domain name", + "backup-error-invalid-bucket": "Must contain only lowercase letters, numbers, dots, and hyphens", + "backup-error-invalid-segment-duration": "Must be between 1 and 60 seconds", + "backup-error-load-failed": "Failed to load storage settings", + "backup-error-update-failed": "Failed to update backup status", + "backup-error-save-failed": "Failed to save storage settings", + "backup-error-missing-secret": "Cannot update S3 settings without the secret key. Please re-enter it.", + "backup-segment-duration-placeholder": "6", + "badges": "Badges", + "badges-cosmetic-section": "Cosmetic Badges", + "badges-empty-state": "You haven't been issued any badges yet.", + "badges-failed-load": "Failed to load badges", + "badges-failed-update": "Failed to update badge selection", + "badges-issued-by": "Issued by {$issuer}", + "badges-streamer-section": "Streamer Badges", + "issue-badges": "Issue Badges", + "issue-badges-back-to-definitions": "Badge Definitions", + "issue-badges-badge-name": "Badge Name", + "issue-badges-badge-name-placeholder": "e.g. VIP, Supporter", + "issue-badges-badge-type": "Badge Type", + "issue-badges-choose-image": "Choose Image", + "issue-badges-create-definition": "Create Badge Definition", + "issue-badges-create-definition-description": "Define a new badge type that can be issued to viewers", + "issue-badges-create-definition-subtitle": "Define a new badge type", + "issue-badges-definition-created": "Badge definition created", + "issue-badges-description-optional": "Description (optional)", + "issue-badges-description-placeholder": "Optional description for this badge", + "issue-badges-failed-create": "Failed to create badge definition", + "issue-badges-failed-issue": "Failed to issue badge", + "issue-badges-failed-load": "Failed to load badge definitions", + "issue-badges-image-optional": "Image (optional)", + "issue-badges-image-too-large": "Image must be under 256KB", + "issue-badges-image-web-only": "Image upload is only available on web", + "issue-badges-issue-badge": "Issue Badge", + "issue-badges-issue-badge-description": "Issue the \"{$name}\" badge to a viewer by their DID", + "issue-badges-issued": "Badge issued", + "issue-badges-issued-to": "Issued to {$did}", + "issue-badges-manage-description": "Create badge definitions and issue them to viewers", + "issue-badges-recipient-did": "Recipient DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Tap to issue", + "issue-badges-your-definitions": "Your Badge Definitions" +} diff --git a/js/web/public/locales/es-ES/common.json b/js/web/public/locales/es-ES/common.json new file mode 100644 index 000000000..153dc7a0b --- /dev/null +++ b/js/web/public/locales/es-ES/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Cargando...", + "error": "Error", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Cerrar", + "open": "Abrir", + "ok": "OK", + "yes": "Sí", + "no": "No", + "continue": "Continuar", + "back": "Atrás", + "next": "Siguiente", + "finish": "Finalizar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "update": "Actualizar", + "refresh": "Actualizar", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "search-placeholder": "Buscar...", + "message-input": "Escribe tu mensaje...", + "please-log-in-to-access-this-page": "Por favor, inicia sesión para acceder a esta página", + "go-to-settings": "Ir a Configuración", + "go-back": "Volver", + "welcome-user": "¡Bienvenido, {$username}!", + "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", + "user-offline": "usuario desconectado", + "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está desconectado, pero ellos recomiendan ver:\n *[default] Parece que <1>@{$handle} está desconectado, pero te recomendamos ver:\n }", + "user-offline-no-recommendations": "Parece que <1>@{$handle} está desconectado ahora mismo.\nVuelve más tarde.", + "streaming-title": "transmitiendo {$title}", + "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", + "pds-selector-title": "¿Nuevo en Atmosphere?", + "pds-selector-description": "Necesitarás seleccionar un PDS (Servidor de Datos Personal) para acceder a apps en Atmosphere, como Bluesky, Tangled y Spark.", + "pds-selector-custom-label": "Otro PDS", + "pds-selector-custom-description": "Introduce la URL de tu propio servidor PDS", + "pds-selector-custom-url-label": "URL de PDS personalizado", + "pds-selector-custom-url-placeholder": "https://pds.ejemplo.com", + "pds-selector-learn-more": "Más información sobre el autoalojamiento", + "pds-selector-info": "Cada servidor tiene sus propias políticas y estándares de fiabilidad. Tus datos ATProto viven en el servidor que elijas y puedes migrar más tarde. Nota: Streamplace tiene sus propias reglas de moderación; puedes ser expulsado de Streamplace independientemente del servidor que elijas.", + "pds-selector-read-policies": "Lee los Términos de Servicio y la Política de Privacidad de {$label} antes de continuar.", + "pds-selector-handle-policy-checkbox": "He leído y acepto la política de identificadores" +} diff --git a/js/web/public/locales/es-ES/settings.json b/js/web/public/locales/es-ES/settings.json new file mode 100644 index 000000000..128c6029c --- /dev/null +++ b/js/web/public/locales/es-ES/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Descargar Nueva Actualización", + "check-for-updates": "Buscar Actualizaciones", + "bundled-runtype": "Empaquetado", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Modo de Recuperación", + "modal-latest-version": "Estás usando la última versión.", + "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", + "modal-update-available-title": "Actualización Disponible", + "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", + "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", + "modal-update-failed-title": "Actualización Falló", + "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", + "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", + "use-custom-node": "Usar Nodo Personalizado", + "default-url": "Predeterminado: {$url}", + "enter-custom-node-url": "Introduce la URL del nodo personalizado", + "save-button": "Guardar", + "language-selection": "Idioma", + "language-selection-description": "Elige tu idioma preferido", + "help-translate": "Ayúdanos a traducir Streamplace", + "help-translate-description": "Buscamos voluntarios para ayudar a traducir Streamplace a más idiomas. Si te interesa, contáctanos en Discord o GitHub!", + "currently-translating": "Las traducciones están en camino", + "currently-translating-description": "Algunas partes de la aplicación pueden verse incompletas. ¡Gracias por tu paciencia!", + "debug-recording-title": "Permitir que {$host} grabe tu retransmisión en directo para depuración y mejora del servicio", + "debug-recording-description": "Opcional", + "input-search-languages": "Buscar idiomas...", + "manage-keys": "Gestionar Claves", + "settings-title": "Configuración", + "loading": "Cargando...", + "error": "Error", + "cancel": "Cancelar", + "confirm": "Confirmar", + "welcome-user": "¡Bienvenido, {$username}!", + "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", + "search-placeholder": "Buscar...", + "message-input": "Introduce tu mensaje...", + "success": "Éxito", + "warning": "Aviso", + "info": "Información", + "close": "Cerrar", + "open": "Abrir", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "update": "Actualizar", + "refresh": "Actualizar", + "save": "Guardar", + "cancel-button": "Cancelar", + "ok": "Aceptar", + "yes": "Sí", + "no": "No", + "continue": "Continuar", + "back": "Volver", + "next": "Siguiente", + "finish": "Finalizar", + "about": "Acerca de", + "account": "Cuenta", + "advanced": "Avanzado", + "danmu": "Danmu", + "developer": "Desarrollador", + "languages": "Idiomas", + "privacy-security": "Privacidad y Seguridad", + "streaming": "Transmisión", + "sign-in": "Iniciar Sesión", + "log-out": "Cerrar sesión", + "account-greeting": "Hola, @{$handle}.", + "edit-profile-bluesky": "Editar perfil (en Bluesky)", + "change-name-color": "Cambiar color de nombre", + "key-management": "Gestión de Claves", + "key-manager": "Gestor de Claves", + "your-stream-pubkeys": "Tus Claves Públicas de Transmisión", + "no-keys": "No hay claves configuradas", + "pubkey-description": "Las claves públicas se emparejan con claves de transmisión (usadas en software de transmitiendo) para firmar y verificar tu transmisión", + "keys-count": "{$count ->\n [one] {$count} clave\n [many] {$count} claves\n *[other] {$count} claves\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Integraciones de Webhook", + "webhook-integrations-description": "Conecta servicios externos para recibir actualizaciones en tiempo real sobre tus transmisiones", + "create-webhook": "Crear Webhook", + "edit-webhook": "Editar Webhook", + "delete-webhook": "Eliminar Webhook", + "no-webhooks-yet": "Aún no hay webhooks configurados", + "failed-load-webhooks": "Error al cargar webhooks", + "webhook-will-no-longer-receive-events": "Este webhook ya no recibirá eventos", + "create-first-webhook-description": "Crea tu primer webhook para empezar a recibir eventos de transmisión", + "example-captain-hook": "Capitán Garfio", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Activa en:", + "events-livestream": "Eventos de Transmisión en Directo", + "events-chat": "Eventos de Chat", + "untitled-webhook": "Webhook Sin Título", + "inactive": "Inactivo", + "debug-recording": "Grabación de Depuración", + "danmu-enabled": "Habilitar Danmu", + "danmu-enabled-description": "Muestra mensajes de chat en vivo como comentarios flotantes en tu pantalla", + "danmu-opacity": "Opacidad", + "danmu-speed": "Velocidad", + "danmu-lane-count": "Número de Carriles", + "danmu-max-messages": "Mensajes Máximos", + "app-version-description": "Información de la versión actual", + "confirm-delete": "¿Estás seguro de que quieres eliminar esto?", + "action-cannot-be-undone": "Esta acción no se puede deshacer", + "name-optional": "Nombre (opcional)", + "deleting": "Eliminando...", + "saving": "Guardando...", + "go-to-dashboard": "Ir al Panel", + "need-setup-live-dashboard": "Primero necesitas configurar una transmisión en directo en el panel", + "no-languages-found": "No se encontraron idiomas", + "backup-save": "Guardar ajustes de copia de seguridad", + "backup-saving": "Guardando ajustes de copia de seguridad...", + "backup-secret-key-set-placeholder": "(Contraseña ya establecida)", + "backup-error-invalid-endpoint": "Debe ser un nombre de dominio válido", + "backup-error-invalid-bucket": "Debe contener solo letras minúsculas, números, puntos y guiones", + "backup-error-invalid-segment-duration": "Debe estar entre 1 y 60 segundos", + "backup-error-load-failed": "Error al cargar los ajustes de almacenamiento", + "backup-error-update-failed": "Error al actualizar el estado de la copia de seguridad", + "backup-error-save-failed": "Error al guardar los ajustes de almacenamiento", + "backup-error-missing-secret": "No se pueden actualizar los ajustes de S3 sin la clave secreta. Por favor, vuelva a introducirla.", + "backup-segment-duration-placeholder": "6", + "backup": "Copia de seguridad", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Respaldar automáticamente las grabaciones en almacenamiento compatible con S3", + "backup-connection-url": "URL de conexión", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Punto de conexión", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Contenedor", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Clave de acceso", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Clave secreta", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Mostrar contraseña en la URL", + "show-password-in-url-description": "Mostrar la clave secreta en la URL de conexión (si se introduce)", + "recommendations-to-others": "Recomendaciones a otros", + "recommendations-description": "Comparte hasta 8 streamers que recomiendas a tus espectadores", + "no-recommendations-yet": "No hay recomendaciones configuradas todavía", + "multistream": "Multi-transmisión", + "multistream-targets": "Objetivos de multi-transmisión", + "multistream-description": "Envía automáticamente tus transmisiones en directo de Streamplace a otros servicios de streaming como Twitch o YouTube.", + "create-multistream-target": "Crear objetivo de multi-transmisión", + "untitled-multistream-target": "Objetivo sin título", + "failed-load-multistream-targets": "Error al cargar los objetivos de multi-transmisión. Por favor, inténtalo de nuevo.", + "failed-toggle-multistream-target": "Error al cambiar el estado del objetivo de multi-transmisión. Por favor, inténtalo de nuevo.", + "no-multistream-targets-yet": "¡Aún no hay objetivos!", + "multistream-targets-count": "{$count ->\n [one] {$count} objetivo\n [many] {$count} objetivos\n *[other] {$count} objetivos\n }", + "multistream-delete-target-confirmation": "¿Estás seguro de que quieres eliminar \"{$target}\"?", + "this-action-cannot-be-undone": "Esta acción no se puede deshacer.", + "rtmp-target-name": "Objetivo RTMP", + "rtmp-target-url": "URL de RTMP", + "rtmp-target-name-placeholder": "Mi objetivo de multi-transmisión", + "multistream-create-target": "Crear objetivo", + "multistream-edit-target": "Editar objetivo", + "created": "creado", + "status": "estado", + "branding": "Branding", + "branding-admin": "Administración de branding", + "branding-admin-description": "Personaliza tu instancia de Streamplace. Ten en cuenta que la configuración puede tardar varias horas en propagarse.", + "branding-login-required": "Por favor, inicia sesión para gestionar el branding", + "branding-configuration": "Configuración", + "branding-text-settings": "Configuración de texto", + "branding-colors": "Colores", + "branding-legal-links": "Vínculos legales", + "branding-images": "Imágenes", + "branding-broadcaster-did": "DID del radiodifusor", + "branding-broadcaster-did-description": "Dejar vacío para usar el valor predeterminado del servidor", + "branding-site-title": "Título del sitio", + "branding-site-title-placeholder": "Introduce un nuevo título de sitio", + "branding-site-description": "Descripción del sitio", + "branding-site-description-placeholder": "Introduce la descripción del sitio", + "branding-default-streamer": "Streamer predeterminado", + "branding-default-streamer-none": "Ninguno", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Quitar streamer predeterminado", + "branding-primary-color": "Color principal", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Color de acento", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG o JPEG (máx. 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG o ICO (máx. 100KB)", + "branding-sidebar-bg": "Imagen de fondo de la barra lateral", + "branding-sidebar-bg-description": "SVG, PNG o JPEG (máx. 500kb) - aparece alineada en la parte inferior de la barra lateral, a todo el ancho. Sube una imagen con opacidad para mejores resultados, ya que actualmente no hay una opción de opacidad separada.", + "branding-current": "Actual: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Subir logo", + "branding-delete-logo": "Eliminar logo", + "branding-upload-favicon": "Subir favicon", + "branding-delete-favicon": "Eliminar favicon", + "branding-upload-background": "Subir imagen de fondo", + "branding-delete-background": "Eliminar imagen de fondo", + "branding-web-only": "La subida de imágenes solo está disponible en la web.", + "refresh-branding": "Actualizar recursos de branding", + "branding-add-legal-link": "Agregar vínculo legal", + "branding-edit-legal-link": "Editar vínculo legal", + "branding-legal-link-text-placeholder": "Texto del vínculo (ej., Política de Privacidad)", + "branding-legal-link-url-placeholder": "URL (ej., https://ejemplo.com/privacidad)", + "add": "Agregar", + "active": "Activo", + "optional": "opcional", + "branding-not-authenticated": "Por favor, inicia sesión primero", + "branding-empty-value": "Por favor, introduce un valor", + "branding-update-success": "{$key} actualizado correctamente", + "branding-upload-success": "{$key} subido correctamente", + "branding-delete-success": "{$key} eliminado correctamente", + "branding-upload-failed": "Error al subir", + "branding-delete-failed": "Error al eliminar", + "branding-not-available": "La subida de archivos solo está disponible en la web", + "node-legal-documents": "Documentos específicos del radiodifusor", + "badges": "Insignias", + "badges-cosmetic-section": "Insignias cosméticas", + "badges-empty-state": "Aún no te han otorgado ninguna insignia.", + "badges-failed-load": "Error al cargar las insignias", + "badges-failed-update": "Error al actualizar la selección de insignias", + "badges-issued-by": "Otorgada por {$issuer}", + "badges-streamer-section": "Insignias del streamer", + "issue-badges": "Emitir insignias", + "issue-badges-back-to-definitions": "Definiciones de insignias", + "issue-badges-badge-name": "Nombre de la insignia", + "issue-badges-badge-name-placeholder": "ej. VIP, Colaborador", + "issue-badges-badge-type": "Tipo de insignia", + "issue-badges-choose-image": "Elegir imagen", + "issue-badges-create-definition": "Crear definición de insignia", + "issue-badges-create-definition-description": "Define un nuevo tipo de insignia que se puede otorgar a los espectadores", + "issue-badges-create-definition-subtitle": "Define un nuevo tipo de insignia", + "issue-badges-definition-created": "Definición de insignia creada", + "issue-badges-description-optional": "Descripción (opcional)", + "issue-badges-description-placeholder": "Descripción opcional para esta insignia", + "issue-badges-failed-create": "Error al crear la definición de insignia", + "issue-badges-failed-issue": "Error al emitir la insignia", + "issue-badges-failed-load": "Error al cargar las definiciones de insignias", + "issue-badges-image-optional": "Imagen (opcional)", + "issue-badges-image-too-large": "La imagen debe ser menor de 256KB", + "issue-badges-image-web-only": "La subida de imágenes solo está disponible en la web", + "issue-badges-issue-badge": "Emitir insignia", + "issue-badges-issue-badge-description": "Emite la insignia \"{$name}\" a un espectador mediante su DID", + "issue-badges-issued": "Insignia emitida", + "issue-badges-issued-to": "Emitida a {$did}", + "issue-badges-manage-description": "Crea definiciones de insignias y emítelas a los espectadores", + "issue-badges-recipient-did": "DID del destinatario", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Toca para emitir", + "issue-badges-your-definitions": "Tus definiciones de insignias" +} diff --git a/js/web/public/locales/fr-FR/common.json b/js/web/public/locales/fr-FR/common.json new file mode 100644 index 000000000..339d534c0 --- /dev/null +++ b/js/web/public/locales/fr-FR/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Chargement...", + "error": "Erreur", + "cancel": "Annuler", + "confirm": "Confirmer", + "close": "Fermer", + "open": "Ouvrir", + "ok": "OK", + "yes": "Oui", + "no": "Non", + "continue": "Continuer", + "back": "Retour", + "next": "Suivant", + "finish": "Terminer", + "save": "Enregistrer", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "update": "Mettre à jour", + "refresh": "Actualiser", + "success": "Succès", + "warning": "Avertissement", + "info": "Information", + "search-placeholder": "Rechercher...", + "message-input": "Entrez votre message...", + "please-log-in-to-access-this-page": "Veuillez vous connecter pour accéder à cette page", + "go-to-settings": "Aller aux Paramètres", + "go-back": "Retour", + "welcome-user": "Bienvenue, {$username} !", + "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", + "user-offline": "utilisateur hors ligne", + "user-offline-message": "{$source ->\n [streamer] On dirait que <1>@{$handle} est hors ligne, mais ils recommandent de regarder :\n *[default] On dirait que <1>@{$handle} est hors ligne, mais nous recommandons de regarder :\n }", + "user-offline-no-recommendations": "On dirait que <1>@{$handle} est hors ligne maintenant.\nRevenez plus tard.", + "streaming-title": "diffusion de {$title}", + "viewer-count": "{$count ->\n [0] 0 spectateurs\n [1] 1 spectateur\n *[other] {$count} spectateurs\n }", + "pds-selector-title": "Nouveau sur Atmosphere ?", + "pds-selector-description": "Vous devrez sélectionner un PDS (Serveur de Données Personnel) pour accéder aux applications sur Atmosphere, comme Bluesky, Tangled et Spark.", + "pds-selector-custom-label": "Un autre PDS", + "pds-selector-custom-description": "Saisissez l'URL de votre propre hôte PDS", + "pds-selector-custom-url-label": "URL PDS personnalisée", + "pds-selector-custom-url-placeholder": "https://pds.exemple.com", + "pds-selector-learn-more": "En savoir plus sur l'auto-hébergement", + "pds-selector-info": "Chaque hôte a ses propres politiques et normes de fiabilité. Vos données ATProto se trouvent sur l'hôte que vous choisissez et vous pouvez migrer ultérieurement. Remarque : Streamplace a ses propres règles de modération — vous pouvez être banni de Streamplace quel que soit l'hôte choisi.", + "pds-selector-read-policies": "Lisez les Conditions d'utilisation et la Politique de confidentialité de {$label} avant de continuer.", + "pds-selector-handle-policy-checkbox": "J'ai lu et j'accepte la politique des identifiants" +} diff --git a/js/web/public/locales/fr-FR/settings.json b/js/web/public/locales/fr-FR/settings.json new file mode 100644 index 000000000..693dae2b9 --- /dev/null +++ b/js/web/public/locales/fr-FR/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Télécharger la nouvelle mise à jour", + "check-for-updates": "Vérifier les mises à jour", + "bundled-runtype": "Inclus", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Mode de récupération", + "modal-latest-version": "Vous utilisez la dernière version.", + "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", + "modal-update-available-title": "Mise à jour disponible", + "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", + "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", + "modal-update-failed-title": "Échec de la mise à jour", + "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", + "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", + "use-custom-node": "Utiliser un nœud personnalisé", + "default-url": "Par défaut : {$url}", + "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", + "save-button": "Enregistrer", + "language-selection": "Langue", + "language-selection-description": "Choisissez votre langue préférée", + "help-translate": "Aidez-nous à traduire Streamplace", + "help-translate-description": "Nous recherchons des bénévoles pour aider à traduire Streamplace dans plus de langues. Si vous êtes intéressé, contactez-nous sur Discord ou GitHub !", + "currently-translating": "Les traductions sont en cours", + "currently-translating-description": "Certaines parties de l'application peuvent sembler incomplètes. Merci pour votre patience !", + "debug-recording-title": "Autoriser {$host} à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", + "debug-recording-description": "Optionnel", + "input-search-languages": "Rechercher des langues...", + "manage-keys": "Gérer les Clés", + "settings-title": "Paramètres", + "loading": "Chargement...", + "error": "Erreur", + "cancel": "Annuler", + "confirm": "Confirmer", + "welcome-user": "Bienvenue, {$username} !", + "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", + "search-placeholder": "Rechercher...", + "message-input": "Saisissez votre message...", + "success": "Succès", + "warning": "Attention", + "info": "Information", + "close": "Fermer", + "open": "Ouvrir", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "update": "Mettre à jour", + "refresh": "Actualiser", + "save": "Enregistrer", + "cancel-button": "Annuler", + "ok": "OK", + "yes": "Oui", + "no": "Non", + "continue": "Continuer", + "back": "Retour", + "next": "Suivant", + "finish": "Terminer", + "about": "À propos", + "account": "Compte", + "advanced": "Avancé", + "danmu": "Danmu", + "developer": "Développeur", + "languages": "Langues", + "privacy-security": "Confidentialité et Sécurité", + "streaming": "Diffusion", + "sign-in": "Se connecter", + "log-out": "Se déconnecter", + "account-greeting": "Salut, @{$handle}.", + "edit-profile-bluesky": "Modifier le profil (sur Bluesky)", + "change-name-color": "Changer la couleur du nom", + "key-management": "Gestion des Clés", + "key-manager": "Gestionnaire de Clés", + "your-stream-pubkeys": "Vos Clés Publiques de Diffusion", + "no-keys": "Aucune clé configurée", + "pubkey-description": "Les clés publiques sont associées aux clés de diffusion (utilisées dans le logiciel de streaming) pour signer et vérifier votre diffusion", + "keys-count": "{$count ->\n [one] {$count} clé\n [many] {$count} clés\n *[other] {$count} clés\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Intégrations de Webhook", + "webhook-integrations-description": "Connectez des services externes pour recevoir des mises à jour en temps réel sur vos diffusions", + "create-webhook": "Créer un Webhook", + "edit-webhook": "Modifier le Webhook", + "delete-webhook": "Supprimer le Webhook", + "no-webhooks-yet": "Aucun webhook configuré pour le moment", + "failed-load-webhooks": "Échec du chargement des webhooks", + "webhook-will-no-longer-receive-events": "Ce webhook ne recevra plus d'événements", + "create-first-webhook-description": "Créez votre premier webhook pour commencer à recevoir des événements de diffusion", + "example-captain-hook": "Capitaine Crochet", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "S'active sur :", + "events-livestream": "Événements de Diffusion en Direct", + "events-chat": "Événements de Chat", + "untitled-webhook": "Webhook Sans Titre", + "inactive": "Inactif", + "debug-recording": "Enregistrement de Débogage", + "danmu-enabled": "Activer Danmu", + "danmu-enabled-description": "Afficher les messages de chat en direct sous forme de commentaires flottants sur votre écran", + "danmu-opacity": "Opacité", + "danmu-speed": "Vitesse", + "danmu-lane-count": "Nombre de Voies", + "danmu-max-messages": "Messages Maximum", + "app-version-description": "Informations sur la version actuelle", + "confirm-delete": "Êtes-vous sûr de vouloir supprimer ceci ?", + "action-cannot-be-undone": "Cette action ne peut pas être annulée", + "name-optional": "Nom (optionnel)", + "deleting": "Suppression...", + "saving": "Enregistrement...", + "go-to-dashboard": "Aller au Tableau de Bord", + "need-setup-live-dashboard": "Vous devez d'abord configurer une diffusion en direct dans le tableau de bord", + "no-languages-found": "Aucune langue trouvée", + "backup-save": "Enregistrer les paramètres de sauvegarde", + "backup-saving": "Enregistrement des paramètres de sauvegarde...", + "backup-secret-key-set-placeholder": "(Mot de passe déjà défini)", + "backup-error-invalid-endpoint": "Doit être un nom de domaine valide", + "backup-error-invalid-bucket": "Doit contenir uniquement des lettres minuscules, des chiffres, des points et des traits d'union", + "backup-error-invalid-segment-duration": "Doit être compris entre 1 et 60 secondes", + "backup-error-load-failed": "Échec du chargement des paramètres de stockage", + "backup-error-update-failed": "Échec de la mise à jour de l'état de la sauvegarde", + "backup-error-save-failed": "Échec de l'enregistrement des paramètres de stockage", + "backup-error-missing-secret": "Impossible de mettre à jour les paramètres S3 sans la clé secrète. Veuillez la saisir à nouveau.", + "backup-segment-duration-placeholder": "6", + "backup": "Sauvegarde", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Sauvegarder automatiquement les enregistrements dans un stockage compatible S3", + "backup-connection-url": "URL de connexion", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Point de terminaison", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Compartiment", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Clé d'accès", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Clé secrète", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Afficher le mot de passe dans l'URL", + "show-password-in-url-description": "Afficher la clé secrète dans l'URL de connexion (si saisie)", + "recommendations-to-others": "Recommandations aux autres", + "recommendations-description": "Partagez jusqu'à 8 streameurs que vous recommandez à vos spectateurs", + "no-recommendations-yet": "Aucune recommandation configurée pour le moment", + "multistream": "Multidiffusion", + "multistream-targets": "Cibles de multidiffusion", + "multistream-description": "Envoyez automatiquement vos diffusions en direct Streamplace vers d'autres services comme Twitch ou YouTube.", + "create-multistream-target": "Créer une cible de multidiffusion", + "untitled-multistream-target": "Cible sans titre", + "failed-load-multistream-targets": "Échec du chargement des cibles de multidiffusion. Veuillez réessayer.", + "failed-toggle-multistream-target": "Échec du basculement de la cible de multidiffusion. Veuillez réessayer.", + "no-multistream-targets-yet": "Aucune cible pour le moment !", + "multistream-targets-count": "{$count ->\n [one] {$count} cible\n [many] {$count} cibles\n *[other] {$count} cibles\n }", + "multistream-delete-target-confirmation": "Êtes-vous sûr de vouloir supprimer \"{$target}\" ?", + "this-action-cannot-be-undone": "Cette action ne peut pas être annulée.", + "rtmp-target-name": "Cible RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Ma cible de multidiffusion", + "multistream-create-target": "Créer la cible", + "multistream-edit-target": "Modifier la cible", + "created": "créé", + "status": "statut", + "branding": "Image de marque", + "branding-admin": "Administration de l'image de marque", + "branding-admin-description": "Personnalisez votre instance Streamplace. Notez que les paramètres peuvent prendre quelques heures à se propager.", + "branding-login-required": "Veuillez vous connecter pour gérer l'image de marque", + "branding-configuration": "Configuration", + "branding-text-settings": "Paramètres de texte", + "branding-colors": "Couleurs", + "branding-legal-links": "Liens légaux", + "branding-images": "Images", + "branding-broadcaster-did": "DID du diffuseur", + "branding-broadcaster-did-description": "Laisser vide pour utiliser la valeur par défaut du serveur", + "branding-site-title": "Titre du site", + "branding-site-title-placeholder": "Saisir un nouveau titre de site", + "branding-site-description": "Description du site", + "branding-site-description-placeholder": "Saisir la description du site", + "branding-default-streamer": "Streameur par défaut", + "branding-default-streamer-none": "Aucun", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Effacer le streameur par défaut", + "branding-primary-color": "Couleur principale", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Couleur d'accent", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG ou JPEG (max 500 Ko)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG ou ICO (max 100 Ko)", + "branding-sidebar-bg": "Image d'arrière-plan de la barre latérale", + "branding-sidebar-bg-description": "SVG, PNG ou JPEG (max 500 ko) - apparaît aligné en bas de la barre latérale, pleine largeur. Téléchargez une image avec opacité pour de meilleurs résultats, car il n'existe pas actuellement d'option d'opacité séparée.", + "branding-current": "Actuel : {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Télécharger le logo", + "branding-delete-logo": "Supprimer le logo", + "branding-upload-favicon": "Télécharger le favicon", + "branding-delete-favicon": "Supprimer le favicon", + "branding-upload-background": "Télécharger l'arrière-plan", + "branding-delete-background": "Supprimer l'arrière-plan", + "branding-web-only": "Les téléchargements d'images ne sont disponibles que sur le web.", + "refresh-branding": "Actualiser les ressources de l'image de marque", + "branding-add-legal-link": "Ajouter un lien légal", + "branding-edit-legal-link": "Modifier le lien légal", + "branding-legal-link-text-placeholder": "Texte du lien (ex., Politique de confidentialité)", + "branding-legal-link-url-placeholder": "URL (ex., https://exemple.com/confidentialite)", + "add": "Ajouter", + "active": "Actif", + "optional": "optionnel", + "branding-not-authenticated": "Veuillez d'abord vous connecter", + "branding-empty-value": "Veuillez saisir une valeur", + "branding-update-success": "{$key} mis à jour avec succès", + "branding-upload-success": "{$key} téléchargé avec succès", + "branding-delete-success": "{$key} supprimé avec succès", + "branding-upload-failed": "Échec du téléchargement", + "branding-delete-failed": "Échec de la suppression", + "branding-not-available": "Les téléchargements de fichiers ne sont disponibles que sur le web", + "node-legal-documents": "Documents spécifiques au diffuseur", + "badges": "Badges", + "badges-cosmetic-section": "Badges cosmétiques", + "badges-empty-state": "Vous n'avez pas encore reçu de badges.", + "badges-failed-load": "Échec du chargement des badges", + "badges-failed-update": "Échec de la mise à jour de la sélection de badges", + "badges-issued-by": "Délivré par {$issuer}", + "badges-streamer-section": "Badges du streameur", + "issue-badges": "Délivrer des badges", + "issue-badges-back-to-definitions": "Définitions de badges", + "issue-badges-badge-name": "Nom du badge", + "issue-badges-badge-name-placeholder": "ex. VIP, Supporter", + "issue-badges-badge-type": "Type de badge", + "issue-badges-choose-image": "Choisir une image", + "issue-badges-create-definition": "Créer une définition de badge", + "issue-badges-create-definition-description": "Définissez un nouveau type de badge qui peut être délivré aux spectateurs", + "issue-badges-create-definition-subtitle": "Définir un nouveau type de badge", + "issue-badges-definition-created": "Définition de badge créée", + "issue-badges-description-optional": "Description (optionnelle)", + "issue-badges-description-placeholder": "Description optionnelle pour ce badge", + "issue-badges-failed-create": "Échec de la création de la définition de badge", + "issue-badges-failed-issue": "Échec de la délivrance du badge", + "issue-badges-failed-load": "Échec du chargement des définitions de badges", + "issue-badges-image-optional": "Image (optionnelle)", + "issue-badges-image-too-large": "L'image doit faire moins de 256 Ko", + "issue-badges-image-web-only": "Le téléchargement d'images n'est disponible que sur le web", + "issue-badges-issue-badge": "Délivrer un badge", + "issue-badges-issue-badge-description": "Délivrez le badge \"{$name}\" à un spectateur par son DID", + "issue-badges-issued": "Badge délivré", + "issue-badges-issued-to": "Délivré à {$did}", + "issue-badges-manage-description": "Créez des définitions de badges et délivrez-les aux spectateurs", + "issue-badges-recipient-did": "DID du destinataire", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Appuyer pour délivrer", + "issue-badges-your-definitions": "Vos définitions de badges" +} diff --git a/js/web/public/locales/pt-BR/common.json b/js/web/public/locales/pt-BR/common.json new file mode 100644 index 000000000..2c35c532b --- /dev/null +++ b/js/web/public/locales/pt-BR/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Carregando...", + "error": "Erro", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Fechar", + "open": "Abrir", + "ok": "OK", + "yes": "Sim", + "no": "Não", + "continue": "Continuar", + "back": "Voltar", + "next": "Próximo", + "finish": "Concluir", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "update": "Atualizar", + "refresh": "Atualizar", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "search-placeholder": "Pesquisar...", + "message-input": "Digite sua mensagem...", + "please-log-in-to-access-this-page": "Por favor, faça login para acessar esta página", + "go-to-settings": "Ir para Configurações", + "go-back": "Voltar", + "welcome-user": "Bem-vindo, {$username}!", + "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", + "user-offline": "usuário offline", + "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está offline, mas eles recomendam assistir:\n *[default] Parece que <1>@{$handle} está offline, mas recomendamos assistir:\n }", + "user-offline-no-recommendations": "Parece que <1>@{$handle} está offline agora.\nVolte mais tarde.", + "streaming-title": "transmitindo {$title}", + "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", + "pds-selector-title": "Novo no Atmosphere?", + "pds-selector-description": "Você precisará selecionar um PDS (Servidor de Dados Pessoal) para acessar apps no Atmosphere, como Bluesky, Tangled e Spark.", + "pds-selector-custom-label": "Outro PDS", + "pds-selector-custom-description": "Digite a URL do seu próprio host PDS", + "pds-selector-custom-url-label": "URL do PDS personalizado", + "pds-selector-custom-url-placeholder": "https://pds.exemplo.com", + "pds-selector-learn-more": "Saiba mais sobre auto-hospedagem", + "pds-selector-info": "Cada host tem suas próprias políticas e padrões de confiabilidade. Seus dados ATProto ficam no host que você escolher e você pode migrar depois. Nota: O Streamplace tem suas próprias regras de moderação — você pode ser banido do Streamplace independentemente do host escolhido.", + "pds-selector-read-policies": "Leia os Termos de Serviço e a Política de Privacidade de {$label} antes de continuar.", + "pds-selector-handle-policy-checkbox": "Li e concordo com a política de identificadores" +} diff --git a/js/web/public/locales/pt-BR/settings.json b/js/web/public/locales/pt-BR/settings.json new file mode 100644 index 000000000..e9c6b7066 --- /dev/null +++ b/js/web/public/locales/pt-BR/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Baixar Nova Atualização", + "check-for-updates": "Verificar Atualizações", + "bundled-runtype": "Empacotado", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Modo de Recuperação", + "modal-latest-version": "Você está usando a versão mais recente.", + "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", + "modal-update-available-title": "Atualização Disponível", + "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", + "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", + "modal-update-failed-title": "Atualização Falhou", + "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", + "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", + "use-custom-node": "Usar Nó Personalizado", + "default-url": "Padrão: {$url}", + "enter-custom-node-url": "Digite a URL do nó personalizado", + "save-button": "Salvar", + "language-selection": "Idioma", + "language-selection-description": "Escolha seu idioma preferido", + "help-translate": "Ajude-nos a traduzir o Streamplace", + "help-translate-description": "Estamos procurando voluntários para ajudar a traduzir o Streamplace para mais idiomas. Se você estiver interessado, entre em contato conosco no Discord ou GitHub!", + "currently-translating": "As traduções estão a caminho", + "currently-translating-description": "Algumas partes do aplicativo podem parecer incompletas. Obrigado pela sua paciência!", + "debug-recording-title": "Permitir que {$host} grave sua transmissão ao vivo para depuração e melhoria do serviço", + "debug-recording-description": "Opcional", + "input-search-languages": "Pesquisar idiomas...", + "manage-keys": "Gerenciar Chaves", + "settings-title": "Configurações", + "loading": "Carregando...", + "error": "Erro", + "cancel": "Cancelar", + "confirm": "Confirmar", + "welcome-user": "Bem-vindo, {$username}!", + "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", + "search-placeholder": "Pesquisar...", + "message-input": "Digite sua mensagem...", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "close": "Fechar", + "open": "Abrir", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "update": "Atualizar", + "refresh": "Atualizar", + "save": "Salvar", + "cancel-button": "Cancelar", + "ok": "OK", + "yes": "Sim", + "no": "Não", + "continue": "Continuar", + "back": "Voltar", + "next": "Próximo", + "finish": "Finalizar", + "about": "Sobre", + "account": "Conta", + "advanced": "Avançado", + "danmu": "Danmu", + "developer": "Desenvolvedor", + "languages": "Idiomas", + "privacy-security": "Privacidade e Segurança", + "streaming": "Transmissão", + "sign-in": "Entrar", + "log-out": "Sair", + "account-greeting": "Olá, @{$handle}.", + "edit-profile-bluesky": "Editar perfil (no Bluesky)", + "change-name-color": "Mudar cor do nome", + "key-management": "Gerenciamento de Chaves", + "key-manager": "Gerenciador de Chaves", + "your-stream-pubkeys": "Suas Chaves Públicas de Transmissão", + "no-keys": "Nenhuma chave configurada", + "pubkey-description": "Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de transmitindo) para assinar e verificar sua transmissão", + "keys-count": "{$count ->\n [one] {$count} chave\n [many] {$count} chaves\n *[other] {$count} chaves\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Integrações de Webhook", + "webhook-integrations-description": "Conecte serviços externos para receber atualizações em tempo real sobre suas transmissões", + "create-webhook": "Criar Webhook", + "edit-webhook": "Editar Webhook", + "delete-webhook": "Excluir Webhook", + "no-webhooks-yet": "Nenhum webhook configurado ainda", + "failed-load-webhooks": "Falha ao carregar webhooks", + "webhook-will-no-longer-receive-events": "Este webhook não receberá mais eventos", + "create-first-webhook-description": "Crie seu primeiro webhook para começar a receber eventos de transmissão", + "example-captain-hook": "Capitão Gancho", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Ativa em:", + "events-livestream": "Eventos de Transmissão ao Vivo", + "events-chat": "Eventos de Chat", + "untitled-webhook": "Webhook Sem Título", + "inactive": "Inativo", + "debug-recording": "Gravação de Depuração", + "danmu-enabled": "Ativar Danmu", + "danmu-enabled-description": "Exibir mensagens de chat ao vivo como comentários flutuantes na sua tela", + "danmu-opacity": "Opacidade", + "danmu-speed": "Velocidade", + "danmu-lane-count": "Número de Faixas", + "danmu-max-messages": "Mensagens Máximas", + "app-version-description": "Informações da versão atual", + "confirm-delete": "Tem certeza de que deseja excluir isso?", + "action-cannot-be-undone": "Esta ação não pode ser desfeita", + "name-optional": "Nome (opcional)", + "deleting": "Excluindo...", + "saving": "Salvando...", + "go-to-dashboard": "Ir para o Painel", + "need-setup-live-dashboard": "Você precisa configurar uma transmissão ao vivo no painel primeiro", + "no-languages-found": "Nenhum idioma encontrado", + "backup-save": "Salvar configurações de backup", + "backup-saving": "Salvando configurações de backup...", + "backup-secret-key-set-placeholder": "(Senha já definida)", + "backup-error-invalid-endpoint": "Deve ser um nome de domínio válido", + "backup-error-invalid-bucket": "Deve conter apenas letras minúsculas, números, pontos e hifens", + "backup-error-invalid-segment-duration": "Deve estar entre 1 e 60 segundos", + "backup-error-load-failed": "Falha ao carregar as configurações de armazenamento", + "backup-error-update-failed": "Falha ao atualizar o status do backup", + "backup-error-save-failed": "Falha ao salvar as configurações de armazenamento", + "backup-error-missing-secret": "Não é possível atualizar as configurações do S3 sem a chave secreta. Por favor, insira-a novamente.", + "backup-segment-duration-placeholder": "6", + "backup": "Backup", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Fazer backup automaticamente das gravações em armazenamento compatível com S3", + "backup-connection-url": "URL de conexão", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Endpoint", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Bucket", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Chave de acesso", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Chave secreta", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Mostrar senha na URL", + "show-password-in-url-description": "Exibir a chave secreta na URL de conexão (se inserida)", + "recommendations-to-others": "Recomendações para outros", + "recommendations-description": "Compartilhe até 8 streamers que você recomenda aos seus espectadores", + "no-recommendations-yet": "Nenhuma recomendação configurada ainda", + "multistream": "Multitransmissão", + "multistream-targets": "Destinos de multitransmissão", + "multistream-description": "Envie automaticamente suas transmissões ao vivo do Streamplace para outros serviços como Twitch ou YouTube.", + "create-multistream-target": "Criar destino de multitransmissão", + "untitled-multistream-target": "Destino sem título", + "failed-load-multistream-targets": "Falha ao carregar os destinos de multitransmissão. Por favor, tente novamente.", + "failed-toggle-multistream-target": "Falha ao alternar o destino de multitransmissão. Por favor, tente novamente.", + "no-multistream-targets-yet": "Nenhum destino ainda!", + "multistream-targets-count": "{$count ->\n [one] {$count} destino\n [many] {$count} destinos\n *[other] {$count} destinos\n }", + "multistream-delete-target-confirmation": "Tem certeza de que deseja excluir \"{$target}\"?", + "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", + "rtmp-target-name": "Destino RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Meu destino de multitransmissão", + "multistream-create-target": "Criar destino", + "multistream-edit-target": "Editar destino", + "created": "criado", + "status": "status", + "branding": "Identidade visual", + "branding-admin": "Administração de identidade visual", + "branding-admin-description": "Personalize sua instância do Streamplace. As configurações podem levar algumas horas para se propagar.", + "branding-login-required": "Faça login para gerenciar a identidade visual", + "branding-configuration": "Configuração", + "branding-text-settings": "Configurações de texto", + "branding-colors": "Cores", + "branding-legal-links": "Links legais", + "branding-images": "Imagens", + "branding-broadcaster-did": "DID do transmissor", + "branding-broadcaster-did-description": "Deixe vazio para usar o padrão do servidor", + "branding-site-title": "Título do site", + "branding-site-title-placeholder": "Digite o novo título do site", + "branding-site-description": "Descrição do site", + "branding-site-description-placeholder": "Digite a descrição do site", + "branding-default-streamer": "Streamer padrão", + "branding-default-streamer-none": "Nenhum", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Limpar streamer padrão", + "branding-primary-color": "Cor principal", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Cor de destaque", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG ou JPEG (máx. 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG ou ICO (máx. 100KB)", + "branding-sidebar-bg": "Imagem de fundo da barra lateral", + "branding-sidebar-bg-description": "SVG, PNG ou JPEG (máx. 500kb) - aparece alinhada na parte inferior da barra lateral, largura total. Faça upload de uma imagem com opacidade para melhores resultados, pois atualmente não há uma opção de opacidade separada.", + "branding-current": "Atual: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Enviar logo", + "branding-delete-logo": "Excluir logo", + "branding-upload-favicon": "Enviar favicon", + "branding-delete-favicon": "Excluir favicon", + "branding-upload-background": "Enviar plano de fundo", + "branding-delete-background": "Excluir plano de fundo", + "branding-web-only": "O upload de imagens só está disponível na web.", + "refresh-branding": "Atualizar recursos de identidade visual", + "branding-add-legal-link": "Adicionar link legal", + "branding-edit-legal-link": "Editar link legal", + "branding-legal-link-text-placeholder": "Texto do link (ex., Política de Privacidade)", + "branding-legal-link-url-placeholder": "URL (ex., https://exemplo.com/privacidade)", + "add": "Adicionar", + "active": "Ativo", + "optional": "opcional", + "branding-not-authenticated": "Faça login primeiro", + "branding-empty-value": "Por favor, insira um valor", + "branding-update-success": "{$key} atualizado com sucesso", + "branding-upload-success": "{$key} enviado com sucesso", + "branding-delete-success": "{$key} excluído com sucesso", + "branding-upload-failed": "Falha ao enviar", + "branding-delete-failed": "Falha ao excluir", + "branding-not-available": "O upload de arquivos só está disponível na web", + "node-legal-documents": "Documentos específicos do transmissor", + "badges": "Emblemas", + "badges-cosmetic-section": "Emblemas cosméticos", + "badges-empty-state": "Você ainda não recebeu nenhum emblema.", + "badges-failed-load": "Falha ao carregar emblemas", + "badges-failed-update": "Falha ao atualizar a seleção de emblemas", + "badges-issued-by": "Emitido por {$issuer}", + "badges-streamer-section": "Emblemas do streamer", + "issue-badges": "Emitir emblemas", + "issue-badges-back-to-definitions": "Definições de emblemas", + "issue-badges-badge-name": "Nome do emblema", + "issue-badges-badge-name-placeholder": "ex. VIP, Apoiador", + "issue-badges-badge-type": "Tipo de emblema", + "issue-badges-choose-image": "Escolher imagem", + "issue-badges-create-definition": "Criar definição de emblema", + "issue-badges-create-definition-description": "Defina um novo tipo de emblema que pode ser emitido para espectadores", + "issue-badges-create-definition-subtitle": "Defina um novo tipo de emblema", + "issue-badges-definition-created": "Definição de emblema criada", + "issue-badges-description-optional": "Descrição (opcional)", + "issue-badges-description-placeholder": "Descrição opcional para este emblema", + "issue-badges-failed-create": "Falha ao criar a definição de emblema", + "issue-badges-failed-issue": "Falha ao emitir o emblema", + "issue-badges-failed-load": "Falha ao carregar as definições de emblemas", + "issue-badges-image-optional": "Imagem (opcional)", + "issue-badges-image-too-large": "A imagem deve ter menos de 256KB", + "issue-badges-image-web-only": "O upload de imagens só está disponível na web", + "issue-badges-issue-badge": "Emitir emblema", + "issue-badges-issue-badge-description": "Emita o emblema \"{$name}\" para um espectador pelo seu DID", + "issue-badges-issued": "Emblema emitido", + "issue-badges-issued-to": "Emitido para {$did}", + "issue-badges-manage-description": "Crie definições de emblemas e emita-os para espectadores", + "issue-badges-recipient-did": "DID do destinatário", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Toque para emitir", + "issue-badges-your-definitions": "Suas definições de emblemas" +} diff --git a/js/web/public/locales/ro-RO/common.json b/js/web/public/locales/ro-RO/common.json new file mode 100644 index 000000000..a0f1f2766 --- /dev/null +++ b/js/web/public/locales/ro-RO/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Se încarcă...", + "error": "Eroare", + "cancel": "Anulare", + "confirm": "Confirmare", + "close": "Închidere", + "open": "Deschidere", + "ok": "OK", + "yes": "Da", + "no": "Nu", + "continue": "Continuare", + "back": "Înapoi", + "next": "Următorul", + "finish": "Finalizare", + "save": "Salvare", + "delete": "Ștergere", + "edit": "Editare", + "create": "Creare", + "update": "Actualizare", + "refresh": "Reîmprospătare", + "success": "Succes", + "warning": "Avertisment", + "info": "Informație", + "search-placeholder": "Căutare...", + "message-input": "Introduceți-vă mesajul...", + "please-log-in-to-access-this-page": "Vă rugăm să vă conectați pentru a accesa această pagină", + "go-to-settings": "Mergeți la Setări", + "go-back": "Mergeți înapoi", + "welcome-user": "Bun venit, {$username}!", + "notification-count": "{$count ->\n [0] Nici o notificare\n [1] O notificare\n *[other] {$count} (de) notificări\n }", + "user-offline": "utilizatorul este offline", + "user-offline-message": "{$source ->\n [streamer] Se pare că <1>@{$handle} este offline, dar recomandă să vizionați:\n *[default] Se pare că <1>@{$handle} este offline, dar vă recomandăm să vizionați:\n }", + "user-offline-no-recommendations": "Se pare că <1>@{$handle} este offline acum.\nReveniți mai târziu.", + "streaming-title": "transmisie în direct {$title}", + "viewer-count": "{$count ->\n [0] 0 vizionări\n [1] 1 vizionare\n *[other] {$count} (de) vizionări\n }", + "pds-selector-title": "Nou pe Atmosphere?", + "pds-selector-description": "Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark.", + "pds-selector-custom-label": "Alt PDS", + "pds-selector-custom-description": "Introduceți adresa URL a propriei gazde PDS", + "pds-selector-custom-url-label": "URL PDS personalizat", + "pds-selector-custom-url-placeholder": "https://pds.exemplu.ro", + "pds-selector-learn-more": "Aflați mai multe despre găzduirea proprie", + "pds-selector-info": "Fiecare gazdă are propriile politici și standarde de fiabilitate. Datele dvs. ATProto se află pe gazda pe care o alegeți și le puteți migra ulterior. Notă: Streamplace are propriile reguli de moderare - accesul dvs. poate fi interzis pe Streamplace indiferent de gazda pe care o alegeți.", + "pds-selector-read-policies": "Citiți Termenii și condițiile și Politica de confidențialitate ale {$label} înainte de a continua.", + "pds-selector-handle-policy-checkbox": "Am citit și sunt de acord cu politica de gestionare" +} diff --git a/js/web/public/locales/ro-RO/settings.json b/js/web/public/locales/ro-RO/settings.json new file mode 100644 index 000000000..0d25901dd --- /dev/null +++ b/js/web/public/locales/ro-RO/settings.json @@ -0,0 +1,206 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Descărcați noua actualizare", + "check-for-updates": "Căutați actualizări", + "bundled-runtype": "Bundled", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Mod de recuperare", + "modal-latest-version": "Aveți cea mai recentă versiune.", + "modal-no-update-available": "Aveți cea mai recentă versiune de Streamplace, ura!", + "modal-update-available-title": "Actualizare disponibilă", + "modal-update-available-description": "O nouă versiune de Streamplace este gata de descărcare", + "modal-update-failed": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", + "modal-update-failed-title": "Actualizare eșuată", + "modal-update-failed-description": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", + "button-reload-app-on-update": "Aplicați actualizarea (va reîncărca aplicația)", + "use-custom-node": "Utilizați nodul personalizat", + "default-url": "Default: {$url}", + "enter-custom-node-url": "Introduceți adresa URL a nodului personalizat", + "save-button": "Salvare", + "language-selection": "Limbă", + "language-selection-description": "Alegeți-vă limba preferată", + "input-search-languages": "Căutați limbi...", + "help-translate": "Ajutați-ne să traducem Streamplace", + "help-translate-description": "Căutăm voluntari care să ne ajute la traducerea Streamplace în mai multe limbi. Dacă sunteți interesați, vă rugăm să ne contactați pe Discord sau GitHub!", + "currently-translating": "Traducerile sunt pe drum", + "currently-translating-description": "Este posibil ca unele părți ale aplicației să pară incomplete. Vă mulțumim pentru răbdare!", + "debug-recording-title": "Permiteți-i lui {$host} să înregistreze transmisiunea live pentru depanare și îmbunătățirea serviciului", + "debug-recording-description": "Opțional", + "manage-keys": "Gestionare chei", + "settings-title": "Setări", + "about": "Despre", + "account": "Cont", + "advanced": "Complex", + "danmu": "Danmu", + "developer": "Dezvoltator", + "languages": "Limbi", + "privacy-security": "Confidențialitate și securitate", + "streaming": "Streaming", + "cancel": "Anulare", + "create": "Creare", + "delete": "Ștergere", + "refresh": "Reîmprospătare", + "sign-in": "Conectare", + "update": "Actualizare", + "log-out": "Deconectare", + "optional": "opțional", + "account-greeting": "Hei, @{$handle}.", + "edit-profile-bluesky": "Editare profil (pe Bluesky)", + "change-name-color": "Schimbați culoarea numelui", + "key-management": "Gestionare chei", + "key-manager": "Manager cheie", + "your-stream-pubkeys": "Cheile publice ale transmisiunii dvs.", + "no-keys": "Nicio cheie configurată", + "pubkey-description": "Cheile publice sunt asociate cu cheile de transmisiune (utilizate în software-ul de streaming) pentru a semna și verifica transmisiunea.", + "keys-count": "{$count ->\n [one] {$count} cheie\n *[other] {$count} chei\n }", + "recommendations": "Recomandări", + "manage-recommendations": "Gestionați recomandările", + "recommendations-to-others": "Recomandări pentru Alții", + "recommendations-description": "Distribuie până la 8 streameri pe care îi recomandați spectatorilor dvs.", + "no-recommendations-yet": "Nu au fost configurate recomandări încă", + "add-recommendation": "Adăugare recomandare", + "streamer-did": "DID Streamer", + "recommendations-count": "{$count ->\n [one] {$count} recomandare\n *[other] {$count} (de) recomandări\n }", + "webhooks": "Webhook-uri", + "webhook-integrations": "Integrari Webhook", + "webhook-integrations-description": "Conectați servicii externe pentru a primi actualizări în timp real despre transmisiunile dvs.", + "create-webhook": "Creare Webhook", + "edit-webhook": "Editare Webhook", + "delete-webhook": "Ștergere Webhook", + "no-webhooks-yet": "Nu sunt încă configurate webhook-uri", + "failed-load-webhooks": "Nu s-au putut încărca webhook-urile", + "webhook-will-no-longer-receive-events": "Acest webhook nu va mai primi evenimente", + "create-first-webhook-description": "Creați-vă primul webhook pentru a începe să primiți evenimente de transmisiune", + "example-captain-hook": "Captain Hook", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} (de) webhook-uri\n }", + "activates-on": "Se activează la:", + "events": "Evenimente", + "events-livestream": "Evenimente în transmisiuni", + "events-chat": "Evenimente de conversașie", + "untitled-webhook": "Webhook fără titlu", + "inactive": "Inactiv", + "active": "Activ", + "multistream": "Multi-transmisiune", + "multistream-targets": "Ținte multi-transmisiune", + "multistream-description": "Distribuie automat transmisiunile tale live Streamplace către alte servicii de streaming precum Twitch sau YouTube.", + "create-multistream-target": "Creare țintă multi-transmisiune", + "untitled-multistream-target": "Țintă fără titlu", + "failed-load-multistream-targets": "Nu s-au putut încărca țintele multi-transmisiune. Vă rugăm să încercați din nou.", + "failed-toggle-multistream-target": "Nu s-a putut comuta ținta multi-transmisiune. Vă rugăm să încercați din nou.", + "failed-delete-multistream-target": "Ștergerea țintei multi-transmisiune nu a reușit. Încercați din nou.", + "no-multistream-targets-yet": "Nu există încă ținte!", + "multistream-targets-count": "{$count ->\n [one] {$count} țintă\n *[other] {$count} (de) ținte\n }", + "multistream-delete-target-confirmation": "Sunteți sigur că vreți să ștergeți „{$target}”?", + "this-action-cannot-be-undone": "Această acțiune nu poate fi anulată.", + "rtmp-target-name": "Țintă RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Ținta mea transmisiune", + "multistream-create-target": "Creare țintă", + "multistream-edit-target": "Editare țintă", + "created": "creată", + "status": "stare", + "debug-recording": "Depanare înregistrare", + "danmu-enabled": "Activare Danmu", + "danmu-enabled-description": "Afișați mesajele din chatul live ca comentarii plutitoare pe ecran", + "danmu-opacity": "Opacitate", + "danmu-speed": "Viteză", + "danmu-lane-count": "Număr de benzi", + "danmu-max-messages": "Mesaje maxime", + "app-version-description": "Nu sunt disponibile actualizări în acest moment", + "confirm-delete": "Sigur vreți să ștergeți asta?", + "action-cannot-be-undone": "Această acțiune nu poate fi anulată", + "name-optional": "Nume (opțional)", + "deleting": "Se șterge...", + "saving": "Se salvează...", + "go-to-dashboard": "Mergeți la tabloul de bord", + "need-setup-live-dashboard": "Trebuie să configurați mai întâi transmisiunile? Vizitați tabloul de bord al transmisiunilor", + "no-languages-found": "Nu s-au găsit limbi", + "branding": "Branding", + "branding-admin": "Administrarea brandingului", + "branding-admin-description": "Personalizați-vă instanța Streamplace. Rețineți că propagarea setărilor poate dura câteva ore.", + "branding-login-required": "Vă rugăm să vă conectați pentru a gestiona brandingul", + "branding-configuration": "Configurare", + "branding-text-settings": "Setări text", + "branding-colors": "Culori", + "branding-legal-links": "Link-uri legale", + "branding-images": "Imagini", + "branding-broadcaster-did": "DID radiodifuzor", + "branding-broadcaster-did-description": "Lăsați gol pentru a utiliza valorile implicite ale serverului", + "branding-site-title": "Titlul site-ului", + "branding-site-title-placeholder": "Introduceți un nou titlu de site", + "branding-site-description": "Descrierea site-ului", + "branding-site-description-placeholder": "Introduceți descrierea site-ului", + "branding-default-streamer": "Streamer implicit", + "branding-default-streamer-none": "Nimic", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Ștergeți Streamerul implicit", + "branding-primary-color": "Culoare primară", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Culoare de accent", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG, sau ICO (maxim 100KB)", + "branding-sidebar-bg": "Imagine de fundal din bara laterală", + "branding-sidebar-bg-description": "SVG, PNG sau JPEG (maxim 500kb) - apare aliniat la partea de jos a barei laterale, pe lățime completă. Încărcați o imagine cu opacitate pentru cele mai bune rezultate, deoarece în prezent nu există o opțiune separată de opacitate.", + "branding-current": "Current: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Încărcare logo", + "branding-delete-logo": "Ștergere logo", + "branding-upload-favicon": "Încărcare Favicon", + "branding-delete-favicon": "Ștergere Favicon", + "branding-upload-background": "Încărcare imagine de fundal", + "branding-delete-background": "Ștergere imagine de fundal", + "branding-web-only": "Încărcarea imaginilor este disponibilă doar pe web.", + "refresh-branding": "Actualizați elementele de branding", + "branding-add-legal-link": "Adăugați link legal", + "branding-edit-legal-link": "Editați linkul legal", + "branding-legal-link-text-placeholder": "Textul linkului (de exemplu, Politica de confidențialitate)", + "branding-legal-link-url-placeholder": "Adresa URL (de ex., https://example.com/privacy)", + "add": "Adăugare", + "edit": "Editare", + "branding-not-authenticated": "Vă rugăm să vă conectați mai întâi", + "branding-empty-value": "Vă rugăm să introduceți o valoare", + "branding-update-success": "{$key} a fost actualizat cu succes", + "branding-upload-success": "{$key} a fost încărcat cu succes", + "branding-delete-success": "{$key} a fost șters cu succes", + "branding-upload-failed": "Încărcare eșuată", + "branding-delete-failed": "Ștergere eșuată", + "branding-not-available": "Încărcarea de fișiere este disponibilă doar pe web.", + "node-legal-documents": "Documente specifice radiodifuzorului", + "badges": "Insigne", + "badges-cosmetic-section": "Insigne cosmetice", + "badges-empty-state": "Nu ți-a fost acordată nicio insignă încă.", + "badges-failed-load": "Nu s-au putut încărca insignele", + "badges-failed-update": "Nu s-a putut actualiza selecția de insigne", + "badges-issued-by": "Acordată de {$issuer}", + "badges-streamer-section": "Insigne de la streameri", + "issue-badges": "Emitere insigne", + "issue-badges-back-to-definitions": "Definiții insigne", + "issue-badges-badge-name": "Numele insignei", + "issue-badges-badge-name-placeholder": "ex. VIP, Susținător", + "issue-badges-badge-type": "Tipul insignei", + "issue-badges-choose-image": "Alegeți imaginea", + "issue-badges-create-definition": "Creare definiție insignă", + "issue-badges-create-definition-description": "Definiți un nou tip de insignă care poate fi acordată spectatorilor", + "issue-badges-create-definition-subtitle": "Definiți un nou tip de insignă", + "issue-badges-definition-created": "Definiție insignă creată", + "issue-badges-description-optional": "Descriere (opțional)", + "issue-badges-description-placeholder": "Descriere opțională pentru această insignă", + "issue-badges-failed-create": "Nu s-a putut crea definiția insignei", + "issue-badges-failed-issue": "Nu s-a putut emite insigna", + "issue-badges-failed-load": "Nu s-au putut încărca definițiile insignelor", + "issue-badges-image-optional": "Imagine (opțional)", + "issue-badges-image-too-large": "Imaginea trebuie să fie mai mică de 256KB", + "issue-badges-image-web-only": "Încărcarea imaginilor este disponibilă doar pe web", + "issue-badges-issue-badge": "Emite insignă", + "issue-badges-issue-badge-description": "Emite insigna „{$name}\" unui spectator prin DID-ul său", + "issue-badges-issued": "Insignă emisă", + "issue-badges-issued-to": "Emisă pentru {$did}", + "issue-badges-manage-description": "Creați definiții de insigne și emiteți-le spectatorilor", + "issue-badges-recipient-did": "DID destinatar", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Atingeți pentru a emite", + "issue-badges-your-definitions": "Definițiile dvs. de insigne" +} diff --git a/js/web/public/locales/zh-Hans/common.json b/js/web/public/locales/zh-Hans/common.json new file mode 100644 index 000000000..8cf1e39bb --- /dev/null +++ b/js/web/public/locales/zh-Hans/common.json @@ -0,0 +1,46 @@ +{ + "loading": "正在加载...", + "error": "错误", + "cancel": "取消", + "confirm": "确认", + "close": "关闭", + "open": "开启", + "ok": "确定", + "yes": "是", + "no": "否", + "continue": "继续", + "back": "返回", + "next": "下一步", + "finish": "完成", + "save": "保存", + "delete": "删除", + "edit": "编辑", + "create": "创建", + "update": "更新", + "refresh": "刷新", + "success": "成功", + "warning": "警告", + "info": "信息", + "search-placeholder": "搜索...", + "message-input": "输入您的消息...", + "please-log-in-to-access-this-page": "请登录以访问此页面", + "go-to-settings": "前往设置", + "go-back": "返回", + "welcome-user": "欢迎,{$username}!", + "notification-count": "{$count ->\n [0] 无通知\n *[other] {$count} 则通知\n }", + "user-offline": "用户离线", + "user-offline-message": "{$source ->\n [streamer] 看起来 <1>@{$handle} 离线了,但他们推荐观看:\n *[default] 看起来 <1>@{$handle} 离线了,但我们推荐观看:\n }", + "user-offline-no-recommendations": "看起来 <1>@{$handle} 离线了。\n请稍后再来看看。", + "streaming-title": "正在直播 {$title}", + "viewer-count": "{$count} 位观众", + "pds-selector-title": "刚接触 Atmosphere 吗?", + "pds-selector-description": "您需要选择一个 PDS (个人数据服务器) 来访问 Atmosphere 上的应用程序,例如 Bluesky、Tangled 和 Spark。", + "pds-selector-custom-label": "另一个 PDS", + "pds-selector-custom-description": "请输入您自己的 PDS 主机网址", + "pds-selector-custom-url-label": "自定义 PDS 网址", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "了解有关自组主机的更多信息", + "pds-selector-info": "每个主机都有自己的政策和可靠性标准。您的 ATProto 数据存储在您选择的主机上,您可以以后进行迁移。注意:Streamplace 有自己的审核规则——无论您选择哪个主机,都可能会被 Streamplace 禁止使用。", + "pds-selector-read-policies": "在继续之前,请阅读 {$label} 的服务条款和隐私政策。", + "pds-selector-handle-policy-checkbox": "我已阅读并同意处理政策" +} diff --git a/js/web/public/locales/zh-Hans/settings.json b/js/web/public/locales/zh-Hans/settings.json new file mode 100644 index 000000000..cb1c7a673 --- /dev/null +++ b/js/web/public/locales/zh-Hans/settings.json @@ -0,0 +1,241 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "下载新更新", + "check-for-updates": "检查更新", + "bundled-runtype": "捆绑版", + "ota-runtype": "空中下载 (OTA)", + "recovery-runtype": "复原模式", + "modal-latest-version": "您正在使用最新版本。", + "modal-no-update-available": "您已经在使用最新版本的 Streamplace,太棒了!", + "modal-update-available-title": "有可用更新", + "modal-update-available-description": "新版本的 Streamplace 已准备好下载", + "modal-update-failed": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", + "modal-update-failed-title": "更新失败", + "modal-update-failed-description": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", + "button-reload-app-on-update": "套用更新 (将重新加载应用程序)", + "use-custom-node": "使用自定义节点", + "default-url": "默认:{$url}", + "enter-custom-node-url": "输入自定义节点网址", + "save-button": "保存", + "language-selection": "语言", + "language-selection-description": "选择您偏好的语言", + "input-search-languages": "搜索语言...", + "help-translate": "帮助我们翻译 Streamplace", + "help-translate-description": "我们正在寻找志愿者协助将 Streamplace 翻译成更多语言。如果您有兴趣,请在 Discord 或 GitHub 上与我们联系!", + "currently-translating": "翻译正在进行中", + "currently-translating-description": "应用程序的某些部分可能看起来不完整。感谢您的耐心等待!", + "debug-recording-title": "允许 {$host} 录制您的直播串流以进行除错和服务改善", + "debug-recording-description": "可选项", + "manage-keys": "管理密钥", + "settings-title": "设置", + "error": "错误", + "about": "关于", + "account": "帐户", + "advanced": "高级", + "danmu": "弹幕", + "developer": "开发者", + "languages": "语言", + "privacy-security": "隐私与安全", + "streaming": "串流", + "cancel": "取消", + "create": "创建", + "delete": "删除", + "refresh": "刷新", + "sign-in": "登录", + "update": "更新", + "log-out": "登出", + "optional": "选填", + "account-greeting": "嗨,@{$handle}。", + "edit-profile-bluesky": "在 Bluesky 编辑个人数据", + "change-name-color": "更改名称颜色", + "key-management": "密钥管理", + "key-manager": "密钥管理器", + "your-stream-pubkeys": "您的串流公开密钥", + "no-keys": "尚未设置密钥", + "pubkey-description": "公开密钥与串流密钥 (用于串流软件) 配对以签署和验证您的串流", + "keys-count": "{$count} 个密钥", + "backup": "备份", + "backup-enabled": "S3 备份", + "backup-enabled-description": "自动将录影备份到兼容 S3 的存储", + "backup-connection-url": "连接网址", + "backup-connection-url-placeholder": "必须是格式为 s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket 的有效 S3 网址", + "backup-endpoint": "终端", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "存储桶", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "访问密钥", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "秘密密钥", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "backup-region": "地区", + "backup-region-placeholder": "us-east-1", + "backup-test-connection": "测试连接", + "backup-testing-connection": "正在测试连接...", + "backup-connection-successful": "连接成功", + "backup-connection-failed": "连接失败", + "show-password-in-url": "在网址中显示密码", + "show-password-in-url-description": "在连接网址中显示秘密密钥 (若有)", + "requested-seconds-per-segment": "每段秒数", + "requested-seconds-per-segment-description": "设置每段秒数,以请求服务器使用", + "recommendations": "推荐主播", + "manage-recommendations": "管理推荐主播", + "recommendations-to-others": "向他人推荐主播", + "recommendations-description": "向您的观众推荐最多 8 位主播", + "no-recommendations-yet": "尚未配置推荐", + "add-recommendation": "添加推荐", + "streamer-did": "主播 DID", + "recommendations-count": "{$count} 位推荐主播", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook 集成", + "webhook-integrations-description": "连接外部服务以即时接收有关您串流的更新", + "create-webhook": "创建 Webhook", + "edit-webhook": "编辑 Webhook", + "delete-webhook": "删除 Webhook", + "no-webhooks-yet": "尚未设置 Webhook", + "failed-load-webhooks": "加载 Webhook 失败", + "webhook-will-no-longer-receive-events": "此 Webhook 将不再接收事件", + "create-first-webhook-description": "创建您的第一个 Webhook 以开始接收串流事件", + "example-captain-hook": "Hook 船长", + "webhooks-count": "{$count} 个 Webhook", + "activates-on": "触发于:", + "events": "事件", + "events-livestream": "直播串流事件", + "events-chat": "聊天事件", + "untitled-webhook": "未命名的 Webhook", + "inactive": "停用", + "active": "活动", + "multistream": "多重串流", + "multistream-targets": "多重串流目标", + "multistream-description": "自动将您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", + "create-multistream-target": "创建多重串流目标", + "untitled-multistream-target": "未命名目标", + "failed-load-multistream-targets": "加载多重串流目标失败。请重试。", + "failed-toggle-multistream-target": "切换多重串流目标失败。请重试。", + "failed-delete-multistream-target": "删除多重串流目标失败。请重试。", + "no-multistream-targets-yet": "还没有目标!", + "multistream-targets-count": "{$count} 个目标", + "multistream-delete-target-confirmation": "您确定要删除“{$target}”吗?", + "this-action-cannot-be-undone": "此操作无法撤销。", + "rtmp-target-name": "RTMP 目标", + "rtmp-target-url": "RTMP 网址", + "rtmp-target-name-placeholder": "我的多重串流目标", + "multistream-create-target": "创建目标", + "multistream-edit-target": "编辑目标", + "created": "创建于", + "status": "状态", + "debug-recording": "调试录制", + "danmu-enabled": "启用弹幕", + "danmu-enabled-description": "将即时聊天消息以浮动评论的形式显示在您的屏幕上", + "danmu-opacity": "不透明度", + "danmu-speed": "速度", + "danmu-lane-count": "轨道数量", + "danmu-max-messages": "最大消息数", + "app-version-description": "当前没有可用的更新", + "confirm-delete": "您确定要删除吗?", + "action-cannot-be-undone": "此操作无法撤销", + "name-optional": "名称 (选填)", + "deleting": "正在删除...", + "saving": "正在保存...", + "go-to-dashboard": "前往仪表板", + "need-setup-live-dashboard": "需要先设置串流吗?请访问直播仪表板", + "no-languages-found": "找不到语言", + "branding": "品牌", + "branding-admin": "品牌管理", + "branding-admin-description": "自定义您的 Streamplace 实例。请注意,设置可能需要几小时才能生效。", + "branding-login-required": "请登录以管理品牌", + "branding-configuration": "配置", + "branding-text-settings": "文字设置", + "branding-colors": "颜色", + "branding-legal-links": "法律链接", + "branding-images": "图像", + "branding-broadcaster-did": "主播 DID", + "branding-broadcaster-did-description": "留空以使用服务器默认值", + "branding-site-title": "网站标题", + "branding-site-title-placeholder": "输入新网站标题", + "branding-site-description": "网站描述", + "branding-site-description-placeholder": "输入网站描述", + "branding-default-streamer": "默认主播", + "branding-default-streamer-none": "无", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "清除默认主播", + "branding-primary-color": "首要颜色", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "强调色", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "主标志", + "branding-main-logo-description": "SVG、PNG 或 JPEG (最大 500KB)", + "branding-favicon": "网站图标", + "branding-favicon-description": "SVG、PNG 或 ICO (最大 100KB)", + "branding-sidebar-bg": "侧边栏背景图片", + "branding-sidebar-bg-description": "SVG、PNG 或 JPEG (最大 500KB)——显示在侧边栏底部,全宽显示。为了获得最佳效果,请上传一张带有透明度的图片,因为目前没有单独的透明度选项。", + "branding-current": "当前:{$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "上传标志", + "branding-delete-logo": "删除标志", + "branding-upload-favicon": "上传网站图标", + "branding-delete-favicon": "删除网站图标", + "branding-upload-background": "上传背景", + "branding-delete-background": "删除背景", + "branding-web-only": "图像上传仅在网页端可用。", + "refresh-branding": "更新品牌资源", + "branding-add-legal-link": "添加法律链接", + "branding-edit-legal-link": "编辑法律链接", + "branding-legal-link-text-placeholder": "链接文本 (例如:隐私政策)", + "branding-legal-link-url-placeholder": "网址 (例如:https://example.com/privacy)", + "add": "添加", + "edit": "编辑", + "branding-not-authenticated": "请先登录", + "branding-empty-value": "请输入一个数值", + "branding-update-success": "{$key} 更新成功", + "branding-upload-success": "{$key} 上传成功", + "branding-delete-success": "{$key} 删除成功", + "branding-upload-failed": "上传失败", + "branding-delete-failed": "删除失败", + "branding-not-available": "文件上传仅在网页版上可用", + "node-legal-documents": "主播专属文档", + "backup-save": "保存备份设置", + "backup-saving": "正在保存备份设置...", + "backup-secret-key-set-placeholder": "(密码已设置)", + "backup-error-invalid-endpoint": "必须是有效的域名", + "backup-error-invalid-bucket": "必须只包含小写字母、数字、点和连字符", + "backup-error-invalid-segment-duration": "必须在 1 到 60 秒之间", + "backup-error-load-failed": "加载存储设置失败", + "backup-error-update-failed": "更新备份状态失败", + "backup-error-save-failed": "保存存储设置失败", + "backup-error-missing-secret": "没有秘密密钥时无法更新 S3 设置。请重新输入。", + "backup-segment-duration-placeholder": "6", + "badges": "徽章", + "badges-cosmetic-section": "装饰性徽章", + "badges-empty-state": "您还没有获得任何徽章。", + "badges-failed-load": "加载徽章失败", + "badges-failed-update": "更新徽章选择失败", + "badges-issued-by": "由 {$issuer} 颁发", + "badges-streamer-section": "主播徽章", + "issue-badges": "颁发徽章", + "issue-badges-back-to-definitions": "徽章定义", + "issue-badges-badge-name": "徽章名称", + "issue-badges-badge-name-placeholder": "例如 VIP、支持者", + "issue-badges-badge-type": "徽章类型", + "issue-badges-choose-image": "选择图片", + "issue-badges-create-definition": "创建徽章定义", + "issue-badges-create-definition-description": "定义可颁发给观众的新徽章类型", + "issue-badges-create-definition-subtitle": "定义新徽章类型", + "issue-badges-definition-created": "徽章定义已创建", + "issue-badges-description-optional": "描述(可选)", + "issue-badges-description-placeholder": "此徽章的可选描述", + "issue-badges-failed-create": "创建徽章定义失败", + "issue-badges-failed-issue": "颁发徽章失败", + "issue-badges-failed-load": "加载徽章定义失败", + "issue-badges-image-optional": "图片(可选)", + "issue-badges-image-too-large": "图片必须小于 256KB", + "issue-badges-image-web-only": "图片上传仅在网页版可用", + "issue-badges-issue-badge": "颁发徽章", + "issue-badges-issue-badge-description": "通过 DID 将\"{$name}\"徽章颁发给观众", + "issue-badges-issued": "徽章已颁发", + "issue-badges-issued-to": "已颁发给 {$did}", + "issue-badges-manage-description": "创建徽章定义并颁发给观众", + "issue-badges-recipient-did": "收件人 DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "点击颁发", + "issue-badges-your-definitions": "您的徽章定义" +} diff --git a/js/web/public/locales/zh-Hant/common.json b/js/web/public/locales/zh-Hant/common.json new file mode 100644 index 000000000..e1e1614f8 --- /dev/null +++ b/js/web/public/locales/zh-Hant/common.json @@ -0,0 +1,46 @@ +{ + "loading": "載入中...", + "error": "錯誤", + "cancel": "取消", + "confirm": "確認", + "close": "關閉", + "open": "開啟", + "ok": "確定", + "yes": "是", + "no": "否", + "continue": "繼續", + "back": "返回", + "next": "下一步", + "finish": "完成", + "save": "儲存", + "delete": "刪除", + "edit": "編輯", + "create": "建立", + "update": "更新", + "refresh": "重新整理", + "success": "成功", + "warning": "警告", + "info": "資訊", + "search-placeholder": "搜尋...", + "message-input": "輸入您的訊息...", + "please-log-in-to-access-this-page": "請登入以存取此頁面", + "go-to-settings": "前往設定", + "go-back": "返回", + "welcome-user": "歡迎,{$username}!", + "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", + "user-offline": "使用者離線", + "user-offline-message": "{$source ->\n [streamer] 看起來 <1>@{$handle} 離線 了,但他們推薦觀看:\n *[default] 看起來 <1>@{$handle} 離線 了,但我們推薦觀看:\n }", + "user-offline-no-recommendations": "看起來 <1>@{$handle} 離線 了。\n請稍後再來看看。", + "streaming-title": "正在直播 {$title}", + "viewer-count": "{$count ->\n [0] 0 位觀眾\n [1] 1 位觀眾\n *[other] {$count} 位觀眾\n }", + "pds-selector-title": "初次接觸 Atmosphere?", + "pds-selector-description": "您需要選擇一個 PDS(個人資料伺服器)來存取 Atmosphere 上的應用程式,例如 Bluesky、Tangled 和 Spark。", + "pds-selector-custom-label": "其他 PDS", + "pds-selector-custom-description": "輸入您自己的 PDS 主機網址", + "pds-selector-custom-url-label": "自訂 PDS 網址", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "了解更多關於自行架設的資訊", + "pds-selector-info": "每個主機都有自己的政策和可靠性標準。您的 ATProto 資料存放在您選擇的主機上,您可以之後進行遷移。注意:Streamplace 有自己的審核規則——無論您選擇哪個主機,都可能被 Streamplace 封禁。", + "pds-selector-read-policies": "在繼續之前,請閱讀 {$label} 的服務條款和隱私政策。", + "pds-selector-handle-policy-checkbox": "我已閱讀並同意識別碼政策" +} diff --git a/js/web/public/locales/zh-Hant/settings.json b/js/web/public/locales/zh-Hant/settings.json new file mode 100644 index 000000000..29fedd1e4 --- /dev/null +++ b/js/web/public/locales/zh-Hant/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "播家 v{$version}", + "download-new-update": "下載新更新", + "check-for-updates": "檢查更新", + "bundled-runtype": "捆綁版", + "ota-runtype": "空中下載 (OTA)", + "recovery-runtype": "復原模式", + "modal-latest-version": "您正在使用最新版本。", + "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", + "modal-update-available-title": "有可用更新", + "modal-update-available-description": "新版本的直播地已準備好下載", + "modal-update-failed": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", + "modal-update-failed-title": "更新失敗", + "modal-update-failed-description": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", + "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", + "use-custom-node": "使用自訂節點", + "default-url": "預設:{$url}", + "enter-custom-node-url": "輸入自訂節點網址", + "save-button": "儲存", + "language-selection": "語言", + "language-selection-description": "選擇您偏好的語言", + "help-translate": "幫助我們翻譯播家", + "help-translate-description": "我們正在尋找志願者協助將播家翻譯成更多語言。如果您有興趣,請在 Discord 或 GitHub 上與我們聯繫!", + "currently-translating": "翻譯正在進行中", + "currently-translating-description": "應用程式的某些部分可能看起來不完整。感謝您的耐心等待!", + "debug-recording-title": "允許 {$host} 錄製您的直播串流以進行除錯和服務改善", + "debug-recording-description": "可選項目", + "input-search-languages": "搜尋語言...", + "manage-keys": "管理金鑰", + "settings-title": "設定", + "loading": "載入中...", + "error": "錯誤", + "cancel": "取消", + "confirm": "確認", + "welcome-user": "歡迎,{$username}!", + "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", + "search-placeholder": "搜尋...", + "message-input": "請輸入您的訊息...", + "success": "成功", + "warning": "警告", + "info": "資訊", + "close": "關閉", + "open": "開啟", + "delete": "刪除", + "edit": "編輯", + "create": "建立", + "update": "更新", + "refresh": "重新整理", + "save": "儲存", + "cancel-button": "取消", + "ok": "確定", + "yes": "是", + "no": "否", + "continue": "繼續", + "back": "返回", + "next": "下一步", + "finish": "完成", + "about": "關於", + "account": "帳戶", + "advanced": "進階", + "danmu": "彈幕", + "developer": "開發者", + "languages": "語言", + "privacy-security": "隱私與安全", + "streaming": "串流", + "sign-in": "登入", + "log-out": "登出", + "account-greeting": "嗨,@{$handle}。", + "edit-profile-bluesky": "編輯個人資料(在 Bluesky)", + "change-name-color": "變更名稱顏色", + "key-management": "金鑰管理", + "key-manager": "金鑰管理器", + "your-stream-pubkeys": "您的串流公開金鑰", + "no-keys": "尚未設定金鑰", + "pubkey-description": "公開金鑰與串流金鑰(用於串流軟體)配對以簽署和驗證您的串流", + "keys-count": "{$count} 個金鑰", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook 整合", + "webhook-integrations-description": "連接外部服務以即時接收有關您串流的更新", + "create-webhook": "建立 Webhook", + "edit-webhook": "編輯 Webhook", + "delete-webhook": "刪除 Webhook", + "no-webhooks-yet": "尚未設定 webhook", + "failed-load-webhooks": "載入 webhook 失敗", + "webhook-will-no-longer-receive-events": "此 webhook 將不再接收事件", + "create-first-webhook-description": "建立您的第一個 webhook 以開始接收串流事件", + "example-captain-hook": "虎克船長", + "webhooks-count": "{$count ->\n *[other] {$count} 個 webhook\n }", + "activates-on": "觸發於:", + "events-livestream": "直播串流事件", + "events-chat": "聊天事件", + "untitled-webhook": "未命名的 Webhook", + "inactive": "停用", + "debug-recording": "除錯錄製", + "danmu-enabled": "啟用彈幕", + "danmu-enabled-description": "將即時聊天訊息以浮動評論的形式顯示在您的螢幕上", + "danmu-opacity": "不透明度", + "danmu-speed": "速度", + "danmu-lane-count": "軌道數量", + "danmu-max-messages": "最大訊息數", + "app-version-description": "目前版本資訊", + "confirm-delete": "您確定要刪除嗎?", + "action-cannot-be-undone": "此動作無法復原", + "name-optional": "名稱(選填)", + "deleting": "刪除中...", + "saving": "儲存中...", + "go-to-dashboard": "前往控制台", + "need-setup-live-dashboard": "您需要先在控制台中設定直播串流", + "no-languages-found": "找不到語言", + "backup-save": "儲存備份設定", + "backup-saving": "正在儲存備份設定...", + "backup-secret-key-set-placeholder": "(密碼已設定)", + "backup-error-invalid-endpoint": "必須是有效的網域名稱", + "backup-error-invalid-bucket": "只能包含小寫字母、數字、點和連字號", + "backup-error-invalid-segment-duration": "必須介於 1 到 60 秒之間", + "backup-error-load-failed": "無法載入儲存設定", + "backup-error-update-failed": "更新備份狀態失敗", + "backup-error-save-failed": "儲存設定失敗", + "backup-error-missing-secret": "無法在沒有金鑰的情況下更新 S3 設定。請重新輸入。", + "backup-segment-duration-placeholder": "6", + "backup": "備份", + "backup-enabled": "S3 備份", + "backup-enabled-description": "自動將錄影備份到相容 S3 的儲存空間", + "backup-connection-url": "連線網址", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "端點", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "儲存桶", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "存取金鑰", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "秘密金鑰", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "在網址中顯示密碼", + "show-password-in-url-description": "在連線網址中顯示秘密金鑰(若已輸入)", + "recommendations-to-others": "向他人推薦主播", + "recommendations-description": "向您的觀眾推薦最多 8 位主播", + "no-recommendations-yet": "尚未設定推薦", + "multistream": "多重串流", + "multistream-targets": "多重串流目標", + "multistream-description": "自動將您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", + "create-multistream-target": "建立多重串流目標", + "untitled-multistream-target": "未命名目標", + "failed-load-multistream-targets": "載入多重串流目標失敗。請重試。", + "failed-toggle-multistream-target": "切換多重串流目標失敗。請重試。", + "no-multistream-targets-yet": "還沒有目標!", + "multistream-targets-count": "{$count} 個目標", + "multistream-delete-target-confirmation": "您確定要刪除「{$target}」嗎?", + "this-action-cannot-be-undone": "此動作無法復原。", + "rtmp-target-name": "RTMP 目標", + "rtmp-target-url": "RTMP 網址", + "rtmp-target-name-placeholder": "我的多重串流目標", + "multistream-create-target": "建立目標", + "multistream-edit-target": "編輯目標", + "created": "建立於", + "status": "狀態", + "branding": "品牌識別", + "branding-admin": "品牌識別管理", + "branding-admin-description": "自訂您的 Streamplace 執行個體。請注意,設定可能需要幾小時才能生效。", + "branding-login-required": "請登入以管理品牌識別", + "branding-configuration": "設定", + "branding-text-settings": "文字設定", + "branding-colors": "顏色", + "branding-legal-links": "法律連結", + "branding-images": "圖像", + "branding-broadcaster-did": "主播 DID", + "branding-broadcaster-did-description": "留空以使用伺服器預設值", + "branding-site-title": "網站標題", + "branding-site-title-placeholder": "輸入新網站標題", + "branding-site-description": "網站描述", + "branding-site-description-placeholder": "輸入網站描述", + "branding-default-streamer": "預設主播", + "branding-default-streamer-none": "無", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "清除預設主播", + "branding-primary-color": "主要顏色", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "強調色", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "主標誌", + "branding-main-logo-description": "SVG、PNG 或 JPEG(最大 500KB)", + "branding-favicon": "網站圖示", + "branding-favicon-description": "SVG、PNG 或 ICO(最大 100KB)", + "branding-sidebar-bg": "側邊欄背景圖片", + "branding-sidebar-bg-description": "SVG、PNG 或 JPEG(最大 500KB)——顯示在側邊欄底部,全寬顯示。為了最佳效果,請上傳帶有透明度的圖片,因為目前沒有單獨的透明度選項。", + "branding-current": "目前:{$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "上傳標誌", + "branding-delete-logo": "刪除標誌", + "branding-upload-favicon": "上傳網站圖示", + "branding-delete-favicon": "刪除網站圖示", + "branding-upload-background": "上傳背景", + "branding-delete-background": "刪除背景", + "branding-web-only": "圖片上傳僅在網頁版可用。", + "refresh-branding": "更新品牌識別資源", + "branding-add-legal-link": "新增法律連結", + "branding-edit-legal-link": "編輯法律連結", + "branding-legal-link-text-placeholder": "連結文字(例如:隱私政策)", + "branding-legal-link-url-placeholder": "網址(例如:https://example.com/privacy)", + "add": "新增", + "active": "啟用", + "optional": "選填", + "branding-not-authenticated": "請先登入", + "branding-empty-value": "請輸入一個值", + "branding-update-success": "{$key} 更新成功", + "branding-upload-success": "{$key} 上傳成功", + "branding-delete-success": "{$key} 刪除成功", + "branding-upload-failed": "上傳失敗", + "branding-delete-failed": "刪除失敗", + "branding-not-available": "檔案上傳僅在網頁版可用", + "node-legal-documents": "主播專屬文件", + "badges": "徽章", + "badges-cosmetic-section": "裝飾性徽章", + "badges-empty-state": "您尚未獲得任何徽章。", + "badges-failed-load": "載入徽章失敗", + "badges-failed-update": "更新徽章選擇失敗", + "badges-issued-by": "由 {$issuer} 頒發", + "badges-streamer-section": "主播徽章", + "issue-badges": "頒發徽章", + "issue-badges-back-to-definitions": "徽章定義", + "issue-badges-badge-name": "徽章名稱", + "issue-badges-badge-name-placeholder": "例如 VIP、支持者", + "issue-badges-badge-type": "徽章類型", + "issue-badges-choose-image": "選擇圖片", + "issue-badges-create-definition": "建立徽章定義", + "issue-badges-create-definition-description": "定義可頒發給觀眾的新徽章類型", + "issue-badges-create-definition-subtitle": "定義新徽章類型", + "issue-badges-definition-created": "徽章定義已建立", + "issue-badges-description-optional": "描述(選填)", + "issue-badges-description-placeholder": "此徽章的選填描述", + "issue-badges-failed-create": "建立徽章定義失敗", + "issue-badges-failed-issue": "頒發徽章失敗", + "issue-badges-failed-load": "載入徽章定義失敗", + "issue-badges-image-optional": "圖片(選填)", + "issue-badges-image-too-large": "圖片必須小於 256KB", + "issue-badges-image-web-only": "圖片上傳僅在網頁版可用", + "issue-badges-issue-badge": "頒發徽章", + "issue-badges-issue-badge-description": "透過 DID 將「{$name}」徽章頒發給觀眾", + "issue-badges-issued": "徽章已頒發", + "issue-badges-issued-to": "已頒發給 {$did}", + "issue-badges-manage-description": "建立徽章定義並頒發給觀眾", + "issue-badges-recipient-did": "收件者 DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "點擊頒發", + "issue-badges-your-definitions": "您的徽章定義" +} diff --git a/js/web/src/components/header.tsx b/js/web/src/components/header.tsx index 29e634f61..ed9228a38 100644 --- a/js/web/src/components/header.tsx +++ b/js/web/src/components/header.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { ArrowRight, Search, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import useAvatars from "../hooks/use-avatars"; import { EMPTY_LOGIN_SEARCH } from "../lib/login-search"; import { useSession } from "../lib/session"; @@ -15,6 +16,7 @@ interface SearchResult { } export default function Header() { + const { t } = useTranslation("common"); const { state } = useSession(); const userProfile = useUserProfile(); useAvatars(state.status === "authenticated" ? [state.session.did] : []); @@ -164,7 +166,7 @@ export default function Header() { onFocus={() => { if (query.trim() && results.length > 0) setOpen(true); }} - placeholder="Search..." + placeholder={t("search-placeholder")} className="w-full h-8 pl-8 pr-8 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-fg)] placeholder:text-[var(--color-fg-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-ring)]" /> {query && ( @@ -233,7 +235,8 @@ export default function Header() { onClick={() => setOpen(false)} className="flex items-center gap-2 text-center py-2 text-sm text-[var(--color-accent)] hover:bg-[var(--color-bg-overlay)] transition-colors" > - More results + {t("more-results")}{" "} + @@ -247,8 +250,12 @@ export default function Header() { {avatar ? ( - Log in + {t("log-in")} )} diff --git a/js/web/src/components/player/player-controls.tsx b/js/web/src/components/player/player-controls.tsx index d1f41bfe9..2c277229b 100644 --- a/js/web/src/components/player/player-controls.tsx +++ b/js/web/src/components/player/player-controls.tsx @@ -53,6 +53,12 @@ export type PlayerControlsProps = { showStats: boolean; /** Toggle the stats overlay. */ onShowStatsChange: (showStats: boolean) => void; + /** Whether the danmu overlay is visible. Only shown in dev mode. */ + showDanmu: boolean; + /** Toggle the danmu overlay. */ + onShowDanmuChange: (showDanmu: boolean) => void; + /** When true, show developer-only controls like danmu. */ + devMode?: boolean; }; export function PlayerControls({ @@ -69,6 +75,9 @@ export function PlayerControls({ onUseWebRTCChange, showStats, onShowStatsChange, + showDanmu, + onShowDanmuChange, + devMode = false, }: PlayerControlsProps) { const [playing, setPlaying] = useState(false); const [muted, setMuted] = useState(true); @@ -77,6 +86,8 @@ export function PlayerControls({ const [duration, setDuration] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + // Mirror video element state into React. useEffect(() => { const video = videoRef.current; @@ -287,12 +298,17 @@ export function PlayerControls({ /> )} - + - + Stats for nerds + {devMode && ( + + Danmu (chat overlay) + + )} diff --git a/js/web/src/components/player/player.tsx b/js/web/src/components/player/player.tsx index 4adcdd0f4..26a9b0c5b 100644 --- a/js/web/src/components/player/player.tsx +++ b/js/web/src/components/player/player.tsx @@ -36,6 +36,12 @@ export type PlayerProps = { onError?: (message: string) => void; /** Called once playback has actually started. */ onPlaying?: () => void; + /** Whether the danmu overlay is visible. */ + showDanmu?: boolean; + /** Toggle the danmu overlay. */ + onShowDanmuChange?: (show: boolean) => void; + /** Whether to show developer-only controls. */ + devMode?: boolean; }; /** One quality option shown in the player's settings menu. */ @@ -121,6 +127,9 @@ export function Player({ active, onError, onPlaying, + showDanmu = false, + onShowDanmuChange, + devMode = false, }: PlayerProps) { const videoRef = useRef(null); const containerRef = useRef(null); @@ -320,6 +329,9 @@ export function Player({ onUseWebRTCChange={handleWebRTCChange} showStats={showStats} onShowStatsChange={setShowStats} + showDanmu={showDanmu} + onShowDanmuChange={(v) => onShowDanmuChange?.(v)} + devMode={devMode} /> )} diff --git a/js/web/src/components/sidebar.tsx b/js/web/src/components/sidebar.tsx index d192cb015..2fb359034 100644 --- a/js/web/src/components/sidebar.tsx +++ b/js/web/src/components/sidebar.tsx @@ -1,6 +1,7 @@ import { EMPTY_LOGIN_SEARCH } from "@/lib/login-search"; import { Link } from "@tanstack/react-router"; import { House, LogIn, Settings, Users, Video } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Sidebar, SidebarContent, @@ -15,6 +16,7 @@ import { } from "./ui/sidebar"; export default function SidebarComponent() { + const { t } = useTranslation("common"); return ( @@ -23,24 +25,24 @@ export default function SidebarComponent() { - + - Home + {t("nav-home")} - + - + - Following + {t("nav-following")} @@ -49,21 +51,21 @@ export default function SidebarComponent() { - Account + {t("nav-account")} - + - Log in + {t("log-in")} - + - Settings + {t("nav-settings")} diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index d059c31f0..25d1ee961 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -8,6 +8,7 @@ import { SuggestionPluginKey } from "@tiptap/suggestion"; import { EmojiPicker } from "frimousse"; import { Reply, Smile, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { ChatMessageViewHydrated } from "streamplace"; import { useStore } from "zustand"; import { useChatSend } from "../../hooks/use-chat-send"; @@ -161,6 +162,7 @@ function createMentionSuggestion() { } export function ChatInput({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); const { state } = useSession(); const isAuthed = state.status === "authenticated"; const send = useChatSend(store); @@ -198,7 +200,7 @@ export function ChatInput({ store }: { store: LivestreamStore }) { hardBreak: false, }), Placeholder.configure({ - placeholder: "Send a message", + placeholder: t("chat-send-message"), }), Mention.configure({ HTMLAttributes: { @@ -274,7 +276,7 @@ export function ChatInput({ store }: { store: LivestreamStore }) { await send(text); editor.commands.clearContent(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to send message"); + setError(err instanceof Error ? err.message : t("chat-failed-send")); } finally { setSending(false); } @@ -301,9 +303,9 @@ export function ChatInput({ store }: { store: LivestreamStore }) { search={EMPTY_LOGIN_SEARCH} className="text-[var(--color-accent)] hover:underline font-medium" > - Log in + {t("log-in")} {" "} - to chat + {t("chat-log-in-to")} ); } @@ -316,10 +318,9 @@ export function ChatInput({ store }: { store: LivestreamStore }) {
- Replying to{" "} - - {replyToMessage.author.handle || replyToMessage.author.did} - + {t("chat-replying-to", { + handle: replyToMessage.author.handle || replyToMessage.author.did, + })} @@ -379,7 +380,7 @@ export function ChatInput({ store }: { store: LivestreamStore }) { diff --git a/js/web/src/components/stream/chat-panel.tsx b/js/web/src/components/stream/chat-panel.tsx index e1d72ecba..50a16c959 100644 --- a/js/web/src/components/stream/chat-panel.tsx +++ b/js/web/src/components/stream/chat-panel.tsx @@ -7,6 +7,7 @@ import type { LivestreamStore } from "@streamplace/core"; import { segmentize, type Facet, type FacetFeature } from "@streamplace/core"; import { ArrowDown, ArrowUp, Pin, Reply } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { ChatMessageViewHydrated, PlaceStreamBadgeDefs, @@ -27,6 +28,7 @@ export function ChatPanel({ store: LivestreamStore; reversed?: boolean; }) { + const { t } = useTranslation("common"); const { chat, authors, replyToMessage } = useStore( store, useShallow((s) => ({ @@ -97,7 +99,7 @@ export function ChatPanel({ {reversed &&
} {displayMessages.length === 0 ? (
- No messages yet + {t("chat-no-messages")}
) : ( displayMessages.map((msg) => ( @@ -117,7 +119,9 @@ export function ChatPanel({ @@ -202,7 +207,7 @@ function ChatMessage({ type="button" onClick={handlePin} className="p-1 rounded bg-[var(--color-bg-elevated)] border border-[var(--color-border)] shadow-sm text-[var(--color-fg-muted)] hover:text-[var(--color-accent)] hover:bg-[var(--color-bg-overlay)] transition-colors" - aria-label="Pin message" + aria-label={t("chat-pin-message")} > @@ -340,6 +345,7 @@ function UserHandle({ color: { red: number; green: number; blue: number } | undefined; badges?: PlaceStreamBadgeDefs.BadgeView[]; }) { + const { t } = useTranslation("common"); const name = author.displayName || author.handle || author.did; const handle = author.handle; @@ -410,7 +416,7 @@ function UserHandle({ target="_blank" rel="noopener noreferrer" className="flex-shrink-0 text-[#0F73FF] hover:opacity-80" - aria-label="View profile on Bluesky" + aria-label={t("chat-view-profile-bluesky")} > @@ -484,27 +490,12 @@ const BADGE_SRC: Record = { "place.stream.badge.defs#bot": "/robot_2x.png", }; -const BADGE_META: Record = { - "place.stream.badge.defs#mod": { - label: "Moderator", - issuedBy: "Issued by Streamplace", - }, - "place.stream.badge.defs#streamer": { - label: "Streamer", - issuedBy: "Issued by Streamplace", - }, - "place.stream.badge.defs#vip": { - label: "VIP", - issuedBy: "Issued by Streamplace", - }, - "place.stream.badge.defs#bot": { - label: "Bot", - issuedBy: "Self-labeled", - }, - "place.stream.badge.defs#event": { - label: "Event", - issuedBy: "Issued by Streamplace", - }, +const BADGE_I18N_KEYS: Record = { + "place.stream.badge.defs#mod": "badge-moderator", + "place.stream.badge.defs#streamer": "badge-streamer", + "place.stream.badge.defs#vip": "badge-vip", + "place.stream.badge.defs#bot": "badge-bot", + "place.stream.badge.defs#event": "badge-event", }; function shortDid(did: string): string { @@ -514,15 +505,17 @@ function shortDid(did: string): string { return `${did.slice(0, 12)}…`; } -function issuerLabel(badge: PlaceStreamBadgeDefs.BadgeView): string { - // Service-issued badges come from the streamplace node itself. +function issuerLabel( + badge: PlaceStreamBadgeDefs.BadgeView, + t: (key: string, opts?: Record) => string, +): string { if (badge.issuer && badge.issuer.startsWith("did:web:")) { - return "Issued by Streamplace"; + return t("badge-issued-by-streamplace"); } if (badge.issuer) { - return `Issued by ${shortDid(badge.issuer)}`; + return t("badge-issued-by", { issuer: shortDid(badge.issuer) }); } - return "Issued"; + return t("badge-issued"); } function BadgeIcon({ badge }: { badge: PlaceStreamBadgeDefs.BadgeView }) { @@ -538,16 +531,18 @@ function BadgeIcon({ badge }: { badge: PlaceStreamBadgeDefs.BadgeView }) { } function BadgeRow({ badge }: { badge: PlaceStreamBadgeDefs.BadgeView }) { + const { t } = useTranslation("common"); const src = badge.imageUrl || BADGE_SRC[badge.badgeType]; - const meta = BADGE_META[badge.badgeType]; - // Prefer the badge def's own name (e.g. "Contest Winner"), then the - // built-in label for known types, then a humanized fallback. + const i18nKey = BADGE_I18N_KEYS[badge.badgeType]; const rawTag = badge.badgeType.split("#")[1] ?? ""; const label = badge.name?.trim() || - meta?.label || - (rawTag ? rawTag[0].toUpperCase() + rawTag.slice(1) : "Badge"); - const issuedBy = meta?.issuedBy ?? issuerLabel(badge); + (i18nKey ? t(i18nKey) : null) || + (rawTag ? rawTag[0].toUpperCase() + rawTag.slice(1) : t("badge-fallback")); + const isSelfLabeled = badge.badgeType === "place.stream.badge.defs#bot"; + const issuedBy = isSelfLabeled + ? t("badge-self-labeled") + : issuerLabel(badge, t); return (
diff --git a/js/web/src/components/stream/chat-sidebar.tsx b/js/web/src/components/stream/chat-sidebar.tsx index a76b9c4d4..63a0bf31a 100644 --- a/js/web/src/components/stream/chat-sidebar.tsx +++ b/js/web/src/components/stream/chat-sidebar.tsx @@ -1,5 +1,6 @@ import type { LivestreamStore } from "@streamplace/core"; import { ExternalLink } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { formatViewers } from "../../lib/format"; @@ -15,6 +16,7 @@ export function ChatSidebar({ store: LivestreamStore; onClose?: () => void; }) { + const { t } = useTranslation("common"); const state = useStore( store, useShallow((s) => ({ @@ -40,11 +42,11 @@ export function ChatSidebar({ />
- {author?.displayName || author?.handle || "Streamer"} + {author?.displayName || author?.handle || t("streamer-fallback")}
{viewers && (
- {viewers} watching + {t("watching-count", { count: state.viewers ?? 0 })}
)}
@@ -55,7 +57,7 @@ export function ChatSidebar({ target="_blank" rel="noopener noreferrer" className="p-1 rounded hover:bg-[var(--color-bg-overlay)] text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] transition-colors" - title="Pop out chat" + title={t("chat-pop-out")} > @@ -65,7 +67,7 @@ export function ChatSidebar({ type="button" onClick={onClose} className="p-1 rounded hover:bg-[var(--color-bg-overlay)] text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] transition-colors flex-shrink-0" - aria-label="Close chat" + aria-label={t("chat-close")} > s.chat); + const segment = useStore(store, (s) => s.segment); + + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const [activeDanmu, setActiveDanmu] = useState([]); + const processedRef = useRef(new Set()); + const mountTimeRef = useRef(Date.now()); + const lastChatLenRef = useRef(0); + const idCounterRef = useRef(0); + const laneOccupancyRef = useRef(new Array(laneCount).fill(0)); + + const assignLane = useCallback((): number => { + const occ = laneOccupancyRef.current; + let minLane = 0; + let minVal = occ[0]; + for (let i = 1; i < occ.length; i++) { + if (occ[i] < minVal) { + minVal = occ[i]; + minLane = i; + } + } + occ[minLane]++; + return minLane; + }, []); + + const releaseLane = useCallback((lane: number) => { + const occ = laneOccupancyRef.current; + if (occ[lane] > 0) occ[lane]--; + }, []); + + // Process new chat messages into danmu + useEffect(() => { + if (!enabled || containerSize.width === 0) return; + + const newCount = chat.length - lastChatLenRef.current; + if (newCount <= 0) return; + lastChatLenRef.current = chat.length; + + const newMessages = chat.slice(0, newCount).filter((msg) => { + if (processedRef.current.has(msg.uri)) return false; + if (msg.author.did === "did:sys:system") return false; + if (msg.deleted) return false; + const msgTime = new Date(msg.record.createdAt).getTime(); + if (msgTime < mountTimeRef.current) return false; + return true; + }); + + if (newMessages.length === 0) return; + + const toAdd: ActiveDanmu[] = []; + for (const msg of newMessages.slice(0, 10)) { + if (processedRef.current.has(msg.uri)) continue; + processedRef.current.add(msg.uri); + + // Prune old processed entries + if (processedRef.current.size > 200) { + const iter = processedRef.current.values(); + for (let i = 0; i < 100; i++) { + const val = iter.next().value; + if (val) processedRef.current.delete(val); + } + } + + const dur = baseDuration(msg) / speed; + const lane = assignLane(); + toAdd.push({ + id: `danmu-${++idCounterRef.current}`, + message: msg, + lane, + duration: dur, + width: 0, + }); + } + + if (toAdd.length > 0) { + setActiveDanmu((prev) => [...prev, ...toAdd].slice(-maxMessages)); + + // Schedule cleanup + for (const item of toAdd) { + setTimeout(() => { + releaseLane(item.lane); + setActiveDanmu((prev) => prev.filter((d) => d.id !== item.id)); + }, item.duration); + } + } + }, [ + chat.length, + enabled, + speed, + maxMessages, + containerSize.width, + assignLane, + releaseLane, + ]); + + // Calculate video area for positioning + const segVideo = segment?.video?.[0]; + const videoAR = segVideo ? segVideo.width / segVideo.height : 16 / 9; + const containerAR = containerSize.width / containerSize.height; + + let videoWidth: number; + let videoHeight: number; + let videoTop: number; + + if (containerAR > videoAR) { + videoHeight = containerSize.height; + videoWidth = videoHeight * videoAR; + videoTop = 0; + } else { + videoWidth = containerSize.width; + videoHeight = videoWidth / videoAR; + videoTop = (containerSize.height - videoHeight) / 2; + } + + videoTop += TOP_GAP; + videoHeight -= TOP_GAP + BOTTOM_GAP; + + const laneHeight = videoHeight / laneCount; + const fontSize = laneHeight * FONT_SIZE_PERCENTAGE; + + if (!enabled) return null; + + return ( +
{ + if (el) { + const rect = el.getBoundingClientRect(); + if ( + rect.width !== containerSize.width || + rect.height !== containerSize.height + ) { + setContainerSize({ width: rect.width, height: rect.height }); + } + } + }} + > + {containerSize.width > 0 && + activeDanmu.map((d) => ( + { + setActiveDanmu((prev) => + prev.map((item) => + item.id === d.id ? { ...item, width: w } : item, + ), + ); + }} + /> + ))} +
+ ); +} + +const DanmuMessageElement = memo( + ({ + message, + lane, + laneHeight, + videoTop, + fontSize, + duration, + containerWidth, + onWidthMeasured, + }: { + message: ChatMessageViewHydrated; + lane: number; + laneHeight: number; + videoTop: number; + fontSize: number; + duration: number; + containerWidth: number; + onWidthMeasured: (width: number) => void; + }) => { + const ref = useRef(null); + const measuredRef = useRef(false); + + useEffect(() => { + if (ref.current && !measuredRef.current) { + measuredRef.current = true; + onWidthMeasured(ref.current.offsetWidth); + } + }, [onWidthMeasured]); + + const color = getRgbColor(message.chatProfile?.color); + const totalDistance = containerWidth + (ref.current?.offsetWidth ?? 200); + + return ( +
+ {message.record.text} +
+ ); + }, + (prev, next) => + prev.message.uri === next.message.uri && + prev.lane === next.lane && + prev.laneHeight === next.laneHeight && + prev.videoTop === next.videoTop && + prev.fontSize === next.fontSize && + prev.duration === next.duration && + prev.containerWidth === next.containerWidth, +); + +DanmuMessageElement.displayName = "DanmuMessageElement"; diff --git a/js/web/src/components/stream/stream-card.tsx b/js/web/src/components/stream/stream-card.tsx index 4794179f0..fb0d35b2b 100644 --- a/js/web/src/components/stream/stream-card.tsx +++ b/js/web/src/components/stream/stream-card.tsx @@ -4,20 +4,21 @@ // avatar, title, handle, activity label, and tags. Links to /$user. import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; import type { PlaceStreamDefs, PlaceStreamLivestream } from "streamplace"; import { formatViewers } from "../../lib/format"; -const ACTIVITY_LABELS: Record = { - events: "Events", - just_chatting: "Just Chatting", - music: "Music", - art: "Art", - software_dev: "Software Dev", - cooking: "Cooking", - miniatures: "Miniatures", - makers_crafting: "Makers & Crafting", - fitness: "Fitness", - sports: "Sports", +const ACTIVITY_I18N_KEYS: Record = { + events: "activity-events", + just_chatting: "activity-just-chatting", + music: "activity-music", + art: "activity-art", + software_dev: "activity-software-dev", + cooking: "activity-cooking", + miniatures: "activity-miniatures", + makers_crafting: "activity-makers-crafting", + fitness: "activity-fitness", + sports: "activity-sports", }; function displayTag(tag: string): string { @@ -34,6 +35,7 @@ function displayTag(tag: string): string { export function getStreamActivity( record: PlaceStreamLivestream.Record, + t: (key: string) => string, ): string | undefined { if (!record.activity) return undefined; const act = record.activity; @@ -42,7 +44,7 @@ export function getStreamActivity( } if (act.$type === "place.stream.defs#activityLabel") { const label = act as PlaceStreamDefs.ActivityLabel; - return ACTIVITY_LABELS[label.label] ?? label.label; + return t(ACTIVITY_I18N_KEYS[label.label] ?? label.label); } return undefined; } @@ -53,10 +55,11 @@ interface StreamCardProps { } export function StreamCard({ stream, avatarUrl }: StreamCardProps) { + const { t } = useTranslation("common"); const record = stream.record as PlaceStreamLivestream.Record; const handle = stream.author.handle || stream.author.did; - const title = record.title || "A livestream!"; - const activity = getStreamActivity(record); + const title = record.title || t("default-stream-title"); + const activity = getStreamActivity(record, t); const tags = record.tags ?? []; const viewers = stream.viewerCount?.count; const user = handle; @@ -80,7 +83,7 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { {/* Live dot */}
- Live + {t("live-badge")}
{/* Viewer count */} {viewers !== undefined && viewers !== null && ( diff --git a/js/web/src/components/stream/stream-info.tsx b/js/web/src/components/stream/stream-info.tsx index 5add477e3..26d50ad98 100644 --- a/js/web/src/components/stream/stream-info.tsx +++ b/js/web/src/components/stream/stream-info.tsx @@ -2,6 +2,7 @@ import { useStreamplaceUrl } from "@/lib/store/hooks"; import { cn } from "@/lib/utils"; import type { LivestreamStore } from "@streamplace/core"; import { ChevronRight, ClipboardCopy, Plus, Share2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import type { PlaceStreamDefs } from "streamplace"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -18,17 +19,17 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; -const ACTIVITY_LABELS: Record = { - events: "Events", - just_chatting: "Just Chatting", - music: "Music", - art: "Art", - software_dev: "Software Dev", - cooking: "Cooking", - miniatures: "Miniatures", - makers_crafting: "Makers & Crafting", - fitness: "Fitness", - sports: "Sports", +const ACTIVITY_I18N_KEYS: Record = { + events: "activity-events", + just_chatting: "activity-just-chatting", + music: "activity-music", + art: "activity-art", + software_dev: "activity-software-dev", + cooking: "activity-cooking", + miniatures: "activity-miniatures", + makers_crafting: "activity-makers-crafting", + fitness: "activity-fitness", + sports: "activity-sports", }; export function activityLabel( @@ -37,11 +38,12 @@ export function activityLabel( | PlaceStreamDefs.ActivityLabel | { $type: string } | undefined, + t: (key: string) => string, ): string | null { if (!activity) return null; if ("name" in activity && activity.name) return activity.name; if ("label" in activity) - return ACTIVITY_LABELS[activity.label] ?? activity.label; + return t(ACTIVITY_I18N_KEYS[activity.label] ?? activity.label); return null; } @@ -58,6 +60,7 @@ export function StreamInfo({ chatOpen: boolean; onToggleChat: () => void; }) { + const { t } = useTranslation("common"); const state = useStore( store, useShallow((s) => ({ @@ -70,7 +73,7 @@ export function StreamInfo({ const record = state.livestream?.record; const author = state.livestream?.author; const title = record?.title || user; - const activity = activityLabel(record?.activity); + const activity = activityLabel(record?.activity, t); const tags = record?.tags; const viewers = formatViewers(state.viewers); const isLive = liveness === "live"; @@ -96,7 +99,7 @@ export function StreamInfo({ {isLive && viewers && ( - {viewers} watching + {t("watching-count", { count: state.viewers ?? 0 })} )}
@@ -126,7 +129,7 @@ export function StreamInfo({ {sessionState.status === "authenticated" && sessionState.session.did !== author?.did && ( )} @@ -209,6 +212,7 @@ function CopyButton({ type: "vod" | "live"; rkey?: string; }) { + const { t } = useTranslation("common"); const share = nodeBaseURL ? assembleShareLink(nodeBaseURL, type, false, rkey) : undefined; @@ -238,7 +242,7 @@ function CopyButton({ - Copy + {t("share-copy")} { if (share) { @@ -246,7 +250,7 @@ function CopyButton({ } }} > - Copy Link + {t("share-copy-link")} { @@ -257,7 +261,7 @@ function CopyButton({ } }} > - Copy Embed Code + {t("share-copy-embed-code")} { @@ -266,7 +270,7 @@ function CopyButton({ } }} > - Copy Embed URL + {t("share-copy-embed-url")} diff --git a/js/web/src/components/stream/stream-notifications.tsx b/js/web/src/components/stream/stream-notifications.tsx index 4f89aeadd..21cdd60d3 100644 --- a/js/web/src/components/stream/stream-notifications.tsx +++ b/js/web/src/components/stream/stream-notifications.tsx @@ -4,6 +4,7 @@ import type { LivestreamStore } from "@streamplace/core"; import { segmentize, type Facet, type FacetFeature } from "@streamplace/core"; import { EyeOff, Pin, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { ChatMessageViewHydrated, PinnedRecordViewHydrated, @@ -47,6 +48,7 @@ function PinnedNotification({ store: LivestreamStore; comment: PinnedRecordViewHydrated; }) { + const { t } = useTranslation("common"); const { state: sessionState, pdsAgent, did } = useSession(); const streamerDid = useStore(store, (s) => s.livestream?.author.did); const canUnpin = did && streamerDid && did === streamerDid; @@ -142,7 +144,7 @@ function PinnedNotification({ type="button" onClick={handleUnpin} className="p-1 rounded hover:bg-white/10 text-neutral-400 hover:text-neutral-200 transition-colors" - aria-label="Unpin message" + aria-label={t("chat-unpin-message")} > @@ -151,7 +153,7 @@ function PinnedNotification({ type="button" onClick={handleDismiss} className="p-1 rounded hover:bg-white/10 text-neutral-400 hover:text-neutral-200 transition-colors" - aria-label="Dismiss pinned message" + aria-label={t("chat-dismiss-pinned")} > @@ -190,6 +192,7 @@ function PinnedRichText({ } function TeleportNotification({ teleport }: { teleport: any }) { + const { t } = useTranslation("common"); const targetDid = teleport.target; const startsAt = teleport.startsAt ? new Date(teleport.startsAt).getTime() @@ -212,7 +215,7 @@ function TeleportNotification({ teleport }: { teleport: any }) { return (

- Teleporting in{" "} + {t("teleporting-in")}{" "} {display} diff --git a/js/web/src/components/stream/user-offline.tsx b/js/web/src/components/stream/user-offline.tsx index 2bcb9c73e..55a12b825 100644 --- a/js/web/src/components/stream/user-offline.tsx +++ b/js/web/src/components/stream/user-offline.tsx @@ -1,15 +1,20 @@ +import { useTranslation } from "react-i18next"; + // Inline "stream offline" overlay rendered on top of the video element // when the streamer is not currently live. Distinct from the // page-level offline state in routes/$user.tsx (which replaces the // whole player) — this is for the in-page "stream is offline, the // page is still here" case. export function UserOffline({ user }: { user: string }) { + const { t } = useTranslation("common"); return (

-
Stream offline
+
+ {t("stream-offline")} +
- {user} is not currently streaming + {t("user-not-streaming", { user })}
diff --git a/js/web/src/components/stream/video-section.tsx b/js/web/src/components/stream/video-section.tsx index 00f75a1ef..1fff01598 100644 --- a/js/web/src/components/stream/video-section.tsx +++ b/js/web/src/components/stream/video-section.tsx @@ -1,10 +1,12 @@ import type { LivestreamStore } from "@streamplace/core"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import { Player } from "../../components/player/player"; import type { Liveness } from "../../hooks/use-liveness-state"; import { getStreamplaceUrl } from "../../lib/streamplace-url"; +import { DanmuOverlay } from "./danmu-overlay"; import { UserOffline } from "./user-offline"; type Segment = { width: number; height: number } | null; @@ -14,11 +16,15 @@ export function VideoSection({ store, user, liveness, + devMode = false, }: { store: LivestreamStore; user: string; liveness: Liveness; + devMode?: boolean; }) { + const [showDanmu, setShowDanmu] = useState(false); + const state = useStore( store, useShallow((s) => ({ @@ -45,6 +51,10 @@ export function VideoSection({ problems={state.problems} playlistUrl={playlistUrl} thumbnailUrl={thumbnailUrl} + store={store} + showDanmu={showDanmu} + onShowDanmuChange={setShowDanmu} + devMode={devMode} /> ); } @@ -61,6 +71,10 @@ export function VideoSectionInner({ playlistUrl, thumbnailUrl, mode = "live", + store, + showDanmu = false, + onShowDanmuChange, + devMode = false, }: { user: string; liveness: Liveness; @@ -69,7 +83,12 @@ export function VideoSectionInner({ playlistUrl: string; thumbnailUrl: string; mode?: "live" | "vod"; + store?: LivestreamStore; + showDanmu?: boolean; + onShowDanmuChange?: (show: boolean) => void; + devMode?: boolean; }) { + const { t } = useTranslation("common"); const neverLive = liveness === "never-live"; // calculate seg ratio for poster aspect ratio correction @@ -97,14 +116,27 @@ export function VideoSectionInner({ }} /> ) : ( - + + )} + + {/* Danmu overlay sits on top of the video */} + {store && ( + )} {liveness === "live" && (
- Live + {t("live-badge")}
)} @@ -113,9 +145,9 @@ export function VideoSectionInner({
-

Reconnecting…

+

{t("reconnecting")}

- The stream may resume shortly. + {t("stream-may-resume")}

diff --git a/js/web/src/components/video/video-card.tsx b/js/web/src/components/video/video-card.tsx index 17e24ac68..c423fe281 100644 --- a/js/web/src/components/video/video-card.tsx +++ b/js/web/src/components/video/video-card.tsx @@ -4,6 +4,7 @@ // /$user/vod/:tid. import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; import type { PlaceStreamVideo } from "streamplace"; import type { VideoView } from "../../hooks/use-video-list"; import { @@ -12,16 +13,6 @@ import { getVideoThumbnailUrl, } from "../../lib/video"; -function formatCount(count: number): string { - if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`; - } - if (count >= 1_000) { - return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`; - } - return `${count}`; -} - export function VideoCard({ video, avatarUrl, @@ -29,11 +20,12 @@ export function VideoCard({ video: VideoView; avatarUrl?: string; }) { + const { t } = useTranslation("common"); const record = video.record as PlaceStreamVideo.Record; const author = video.author; const user = author.handle || author.did; const tid = getTidFromAtUri(video.uri); - const title = record.title || "Untitled"; + const title = record.title || t("untitled"); const thumbnailUrl = getVideoThumbnailUrl(record, author.did); const duration = formatDuration(record.durationMs); const viewCount = video.viewCounts?.count ?? 0; @@ -103,8 +95,8 @@ export function VideoCard({ @{user}
- {formatCount(viewCount)} view{viewCount === 1 ? "" : "s"} ·{" "} - {formatCount(likeCount)} like{likeCount === 1 ? "" : "s"} + {t("views-count", { count: viewCount })} ·{" "} + {t("likes-count", { count: likeCount })}
diff --git a/js/web/src/hooks/use-dev-mode.ts b/js/web/src/hooks/use-dev-mode.ts new file mode 100644 index 000000000..0d5d2d980 --- /dev/null +++ b/js/web/src/hooks/use-dev-mode.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; + +const DEV_MODE_KEY = "streamplace:dev-mode"; + +export function useDevMode(): [boolean, () => void] { + const [enabled, setEnabled] = useState(() => { + try { + return localStorage.getItem(DEV_MODE_KEY) === "true"; + } catch { + return false; + } + }); + + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key === DEV_MODE_KEY) { + setEnabled(e.newValue === "true"); + } + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + + const toggle = useCallback(() => { + setEnabled((prev) => { + const next = !prev; + try { + localStorage.setItem(DEV_MODE_KEY, String(next)); + } catch {} + return next; + }); + }, []); + + return [enabled, toggle]; +} + +export function isDevMode(): boolean { + try { + return localStorage.getItem(DEV_MODE_KEY) === "true"; + } catch { + return false; + } +} diff --git a/js/web/src/lib/color.ts b/js/web/src/lib/color.ts new file mode 100644 index 000000000..ff592ed6c --- /dev/null +++ b/js/web/src/lib/color.ts @@ -0,0 +1,10 @@ +/** + * Convert a place.stream.chat.profile.Color to a CSS rgb() string. + * Returns a default pink if no color is provided. + */ +export function getRgbColor( + color?: { red: number; green: number; blue: number } | null, +): string { + if (!color) return "#bd6e86"; + return `rgb(${color.red}, ${color.green}, ${color.blue})`; +} diff --git a/js/web/src/lib/store/hooks.ts b/js/web/src/lib/store/hooks.ts index 0cb7e0a88..a8d7f2fba 100644 --- a/js/web/src/lib/store/hooks.ts +++ b/js/web/src/lib/store/hooks.ts @@ -39,6 +39,7 @@ export const useIsReady = () => { return true; }; export const useCachedProfiles = () => useStore((state) => state.profileCache); +export const useChatProfile = () => useStore((state) => state.chatProfile); // PDS Agent (convenience — reads from bluesky slice) export const usePDSAgent = () => useStore((state) => state.pdsAgent); diff --git a/js/web/src/routeTree.gen.ts b/js/web/src/routeTree.gen.ts index a9557135c..897b885e8 100644 --- a/js/web/src/routeTree.gen.ts +++ b/js/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as SettingsPrivacyRouteImport } from './routes/settings/privacy' import { Route as SettingsMultistreamRouteImport } from './routes/settings/multistream' import { Route as SettingsLanguagesRouteImport } from './routes/settings/languages' import { Route as SettingsKeysRouteImport } from './routes/settings/keys' +import { Route as SettingsChatProfileRouteImport } from './routes/settings/chat-profile' import { Route as SettingsBrandingRouteImport } from './routes/settings/branding' import { Route as SettingsBadgesRouteImport } from './routes/settings/badges' import { Route as SettingsBadgeIssuerRouteImport } from './routes/settings/badge-issuer' @@ -31,7 +32,11 @@ import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced import { Route as SettingsAccountRouteImport } from './routes/settings/account' import { Route as SettingsAboutRouteImport } from './routes/settings/about' import { Route as ChatPopoutUserRouteImport } from './routes/chat-popout.$user' +import { Route as EmbedUserIndexRouteImport } from './routes/embed/$user/index' +import { Route as EmbedInfoWidgetUserRouteImport } from './routes/embed/info-widget/$user' +import { Route as EmbedDanmuObsUserRouteImport } from './routes/embed/danmu-obs/$user' import { Route as UserVideoTidRouteImport } from './routes/$user/video/$tid' +import { Route as EmbedUserVideoTidRouteImport } from './routes/embed/$user/video/$tid' const VideosRoute = VideosRouteImport.update({ id: '/videos', @@ -103,6 +108,11 @@ const SettingsKeysRoute = SettingsKeysRouteImport.update({ path: '/keys', getParentRoute: () => SettingsRoute, } as any) +const SettingsChatProfileRoute = SettingsChatProfileRouteImport.update({ + id: '/chat-profile', + path: '/chat-profile', + getParentRoute: () => SettingsRoute, +} as any) const SettingsBrandingRoute = SettingsBrandingRouteImport.update({ id: '/branding', path: '/branding', @@ -143,11 +153,31 @@ const ChatPopoutUserRoute = ChatPopoutUserRouteImport.update({ path: '/chat-popout/$user', getParentRoute: () => rootRouteImport, } as any) +const EmbedUserIndexRoute = EmbedUserIndexRouteImport.update({ + id: '/embed/$user/', + path: '/embed/$user/', + getParentRoute: () => rootRouteImport, +} as any) +const EmbedInfoWidgetUserRoute = EmbedInfoWidgetUserRouteImport.update({ + id: '/embed/info-widget/$user', + path: '/embed/info-widget/$user', + getParentRoute: () => rootRouteImport, +} as any) +const EmbedDanmuObsUserRoute = EmbedDanmuObsUserRouteImport.update({ + id: '/embed/danmu-obs/$user', + path: '/embed/danmu-obs/$user', + getParentRoute: () => rootRouteImport, +} as any) const UserVideoTidRoute = UserVideoTidRouteImport.update({ id: '/$user/video/$tid', path: '/$user/video/$tid', getParentRoute: () => rootRouteImport, } as any) +const EmbedUserVideoTidRoute = EmbedUserVideoTidRouteImport.update({ + id: '/embed/$user/video/$tid', + path: '/embed/$user/video/$tid', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -163,6 +193,7 @@ export interface FileRoutesByFullPath { '/settings/badge-issuer': typeof SettingsBadgeIssuerRoute '/settings/badges': typeof SettingsBadgesRoute '/settings/branding': typeof SettingsBrandingRoute + '/settings/chat-profile': typeof SettingsChatProfileRoute '/settings/keys': typeof SettingsKeysRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute @@ -173,6 +204,10 @@ export interface FileRoutesByFullPath { '/$user/': typeof UserIndexRoute '/settings/': typeof SettingsIndexRoute '/$user/video/$tid': typeof UserVideoTidRoute + '/embed/danmu-obs/$user': typeof EmbedDanmuObsUserRoute + '/embed/info-widget/$user': typeof EmbedInfoWidgetUserRoute + '/embed/$user/': typeof EmbedUserIndexRoute + '/embed/$user/video/$tid': typeof EmbedUserVideoTidRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -187,6 +222,7 @@ export interface FileRoutesByTo { '/settings/badge-issuer': typeof SettingsBadgeIssuerRoute '/settings/badges': typeof SettingsBadgesRoute '/settings/branding': typeof SettingsBrandingRoute + '/settings/chat-profile': typeof SettingsChatProfileRoute '/settings/keys': typeof SettingsKeysRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute @@ -197,6 +233,10 @@ export interface FileRoutesByTo { '/$user': typeof UserIndexRoute '/settings': typeof SettingsIndexRoute '/$user/video/$tid': typeof UserVideoTidRoute + '/embed/danmu-obs/$user': typeof EmbedDanmuObsUserRoute + '/embed/info-widget/$user': typeof EmbedInfoWidgetUserRoute + '/embed/$user': typeof EmbedUserIndexRoute + '/embed/$user/video/$tid': typeof EmbedUserVideoTidRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -213,6 +253,7 @@ export interface FileRoutesById { '/settings/badge-issuer': typeof SettingsBadgeIssuerRoute '/settings/badges': typeof SettingsBadgesRoute '/settings/branding': typeof SettingsBrandingRoute + '/settings/chat-profile': typeof SettingsChatProfileRoute '/settings/keys': typeof SettingsKeysRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute @@ -223,6 +264,10 @@ export interface FileRoutesById { '/$user/': typeof UserIndexRoute '/settings/': typeof SettingsIndexRoute '/$user/video/$tid': typeof UserVideoTidRoute + '/embed/danmu-obs/$user': typeof EmbedDanmuObsUserRoute + '/embed/info-widget/$user': typeof EmbedInfoWidgetUserRoute + '/embed/$user/': typeof EmbedUserIndexRoute + '/embed/$user/video/$tid': typeof EmbedUserVideoTidRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -240,6 +285,7 @@ export interface FileRouteTypes { | '/settings/badge-issuer' | '/settings/badges' | '/settings/branding' + | '/settings/chat-profile' | '/settings/keys' | '/settings/languages' | '/settings/multistream' @@ -250,6 +296,10 @@ export interface FileRouteTypes { | '/$user/' | '/settings/' | '/$user/video/$tid' + | '/embed/danmu-obs/$user' + | '/embed/info-widget/$user' + | '/embed/$user/' + | '/embed/$user/video/$tid' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -264,6 +314,7 @@ export interface FileRouteTypes { | '/settings/badge-issuer' | '/settings/badges' | '/settings/branding' + | '/settings/chat-profile' | '/settings/keys' | '/settings/languages' | '/settings/multistream' @@ -274,6 +325,10 @@ export interface FileRouteTypes { | '/$user' | '/settings' | '/$user/video/$tid' + | '/embed/danmu-obs/$user' + | '/embed/info-widget/$user' + | '/embed/$user' + | '/embed/$user/video/$tid' id: | '__root__' | '/' @@ -289,6 +344,7 @@ export interface FileRouteTypes { | '/settings/badge-issuer' | '/settings/badges' | '/settings/branding' + | '/settings/chat-profile' | '/settings/keys' | '/settings/languages' | '/settings/multistream' @@ -299,6 +355,10 @@ export interface FileRouteTypes { | '/$user/' | '/settings/' | '/$user/video/$tid' + | '/embed/danmu-obs/$user' + | '/embed/info-widget/$user' + | '/embed/$user/' + | '/embed/$user/video/$tid' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -310,6 +370,10 @@ export interface RootRouteChildren { ChatPopoutUserRoute: typeof ChatPopoutUserRoute UserIndexRoute: typeof UserIndexRoute UserVideoTidRoute: typeof UserVideoTidRoute + EmbedDanmuObsUserRoute: typeof EmbedDanmuObsUserRoute + EmbedInfoWidgetUserRoute: typeof EmbedInfoWidgetUserRoute + EmbedUserIndexRoute: typeof EmbedUserIndexRoute + EmbedUserVideoTidRoute: typeof EmbedUserVideoTidRoute } declare module '@tanstack/react-router' { @@ -412,6 +476,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsKeysRouteImport parentRoute: typeof SettingsRoute } + '/settings/chat-profile': { + id: '/settings/chat-profile' + path: '/chat-profile' + fullPath: '/settings/chat-profile' + preLoaderRoute: typeof SettingsChatProfileRouteImport + parentRoute: typeof SettingsRoute + } '/settings/branding': { id: '/settings/branding' path: '/branding' @@ -468,6 +539,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPopoutUserRouteImport parentRoute: typeof rootRouteImport } + '/embed/$user/': { + id: '/embed/$user/' + path: '/embed/$user' + fullPath: '/embed/$user/' + preLoaderRoute: typeof EmbedUserIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/embed/info-widget/$user': { + id: '/embed/info-widget/$user' + path: '/embed/info-widget/$user' + fullPath: '/embed/info-widget/$user' + preLoaderRoute: typeof EmbedInfoWidgetUserRouteImport + parentRoute: typeof rootRouteImport + } + '/embed/danmu-obs/$user': { + id: '/embed/danmu-obs/$user' + path: '/embed/danmu-obs/$user' + fullPath: '/embed/danmu-obs/$user' + preLoaderRoute: typeof EmbedDanmuObsUserRouteImport + parentRoute: typeof rootRouteImport + } '/$user/video/$tid': { id: '/$user/video/$tid' path: '/$user/video/$tid' @@ -475,6 +567,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UserVideoTidRouteImport parentRoute: typeof rootRouteImport } + '/embed/$user/video/$tid': { + id: '/embed/$user/video/$tid' + path: '/embed/$user/video/$tid' + fullPath: '/embed/$user/video/$tid' + preLoaderRoute: typeof EmbedUserVideoTidRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -486,6 +585,7 @@ interface SettingsRouteChildren { SettingsBadgeIssuerRoute: typeof SettingsBadgeIssuerRoute SettingsBadgesRoute: typeof SettingsBadgesRoute SettingsBrandingRoute: typeof SettingsBrandingRoute + SettingsChatProfileRoute: typeof SettingsChatProfileRoute SettingsKeysRoute: typeof SettingsKeysRoute SettingsLanguagesRoute: typeof SettingsLanguagesRoute SettingsMultistreamRoute: typeof SettingsMultistreamRoute @@ -504,6 +604,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsBadgeIssuerRoute: SettingsBadgeIssuerRoute, SettingsBadgesRoute: SettingsBadgesRoute, SettingsBrandingRoute: SettingsBrandingRoute, + SettingsChatProfileRoute: SettingsChatProfileRoute, SettingsKeysRoute: SettingsKeysRoute, SettingsLanguagesRoute: SettingsLanguagesRoute, SettingsMultistreamRoute: SettingsMultistreamRoute, @@ -527,6 +628,10 @@ const rootRouteChildren: RootRouteChildren = { ChatPopoutUserRoute: ChatPopoutUserRoute, UserIndexRoute: UserIndexRoute, UserVideoTidRoute: UserVideoTidRoute, + EmbedDanmuObsUserRoute: EmbedDanmuObsUserRoute, + EmbedInfoWidgetUserRoute: EmbedInfoWidgetUserRoute, + EmbedUserIndexRoute: EmbedUserIndexRoute, + EmbedUserVideoTidRoute: EmbedUserVideoTidRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/js/web/src/routes/$user/index.tsx b/js/web/src/routes/$user/index.tsx index 8af6e39b4..73a096f5a 100644 --- a/js/web/src/routes/$user/index.tsx +++ b/js/web/src/routes/$user/index.tsx @@ -1,6 +1,7 @@ import { ChatSidebar } from "@/components/stream/chat-sidebar"; import { StreamInfo } from "@/components/stream/stream-info"; import { VideoSection } from "@/components/stream/video-section"; +import { useDevMode } from "@/hooks/use-dev-mode"; import { useLivenessState } from "@/hooks/use-liveness-state"; import { getStreamplaceUrl } from "@/lib/streamplace-url"; import { @@ -10,6 +11,7 @@ import { } from "@streamplace/core"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; export const Route = createFileRoute("/$user/")({ component: StreamPage, @@ -114,6 +116,7 @@ function StreamPage() { function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { const liveness = useLivenessState(store); + const [devMode] = useDevMode(); const [chatOpen, setChatOpen] = useState(() => { if (typeof localStorage === "undefined") return true; return localStorage.getItem("streamplace:chat-open") !== "false"; @@ -137,7 +140,12 @@ function StreamBody({ store, user }: { store: LivestreamStore; user: string }) { className={`flex-1 flex min-h-0 gap-4 transition-[margin] duration-300 ease-in-out ${chatOpen ? "mr-[360px]" : "mr-0"}`} >
- +
@@ -177,24 +186,25 @@ function OfflinePage({ user }: { user: string }) { />
-

Stream is offline

+

+ {t("stream-is-offline-title")} +

- {user} is not currently streaming. - Check back later. + {t("user-not-streaming-check-back", { user })}

- Back to home + {t("back-to-home")}
diff --git a/js/web/src/routes/$user/video/$tid.tsx b/js/web/src/routes/$user/video/$tid.tsx index 822f35312..17be4e682 100644 --- a/js/web/src/routes/$user/video/$tid.tsx +++ b/js/web/src/routes/$user/video/$tid.tsx @@ -5,12 +5,14 @@ import { formatDuration } from "@/lib/video"; import { createFileRoute } from "@tanstack/react-router"; import { Download } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; export const Route = createFileRoute("/$user/video/$tid")({ component: VodPage, }); function VodPage() { + const { t } = useTranslation("common"); const { user, tid } = Route.useParams(); const { record, author, loading, error } = useVideoRecord(user, tid); const [downloading, setDownloading] = useState(false); @@ -24,7 +26,7 @@ function VodPage() { }; }, [user, tid]); - const title = record?.title || "Untitled"; + const title = record?.title || t("untitled"); const handleDownload = useCallback(async () => { setDownloading(true); @@ -110,14 +112,14 @@ function VodPage() {

- {loading ? "Loading..." : title} + {loading ? t("loading") : title}

diff --git a/js/web/src/routes/__root.tsx b/js/web/src/routes/__root.tsx index 71df09bba..048a4a132 100644 --- a/js/web/src/routes/__root.tsx +++ b/js/web/src/routes/__root.tsx @@ -10,9 +10,10 @@ import { import { Component, type ReactNode, useEffect, useState } from "react"; import { SidebarInset, SidebarProvider } from "../components/ui/sidebar"; import { getStoredPreference, syncThemeClass } from "../hooks/use-color-scheme"; +import i18next from "../lib/i18n"; /** Routes that should render without sidebar/header chrome. */ -const POPOUT_PREFIXES = ["/chat-popout/"]; +const POPOUT_PREFIXES = ["/chat-popout/", "/embed/"]; export const Route = createRootRoute({ component: RootLayout, @@ -44,17 +45,17 @@ class ErrorBoundary extends Component< return (

- Something went wrong + {i18next.t("something-went-wrong")}

- {this.state.error.message || "An unexpected error occurred."} + {this.state.error.message || i18next.t("unexpected-error")}

); diff --git a/js/web/src/routes/embed/$user/index.tsx b/js/web/src/routes/embed/$user/index.tsx new file mode 100644 index 000000000..7b5013bb4 --- /dev/null +++ b/js/web/src/routes/embed/$user/index.tsx @@ -0,0 +1,26 @@ +import { Player } from "@/components/player/player"; +import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; + +export const Route = createFileRoute("/embed/$user/")({ + component: EmbedLive, +}); + +function EmbedLive() { + const { user } = Route.useParams(); + + const { playlistUrl, thumbnailUrl } = useMemo(() => { + const base = getStreamplaceUrl(); + return { + playlistUrl: `${base}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(user)}`, + thumbnailUrl: `${base}/api/playback/${encodeURIComponent(user)}/stream.jpg`, + }; + }, [user]); + + return ( +
+ +
+ ); +} diff --git a/js/web/src/routes/embed/$user/video/$tid.tsx b/js/web/src/routes/embed/$user/video/$tid.tsx new file mode 100644 index 000000000..36414b407 --- /dev/null +++ b/js/web/src/routes/embed/$user/video/$tid.tsx @@ -0,0 +1,27 @@ +import { Player } from "@/components/player/player"; +import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; + +export const Route = createFileRoute("/embed/$user/video/$tid")({ + component: EmbedVideo, +}); + +function EmbedVideo() { + const { user, tid } = Route.useParams(); + + const { playlistUrl, thumbnailUrl } = useMemo(() => { + const base = getStreamplaceUrl(); + const uri = `at://${user}/place.stream.video/${tid}`; + return { + playlistUrl: `${base}/xrpc/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(uri)}`, + thumbnailUrl: `${base}/api/playback/${encodeURIComponent(user)}/video/${tid}/thumb.jpg`, + }; + }, [user, tid]); + + return ( +
+ +
+ ); +} diff --git a/js/web/src/routes/embed/danmu-obs/$user.tsx b/js/web/src/routes/embed/danmu-obs/$user.tsx new file mode 100644 index 000000000..a03cfe87a --- /dev/null +++ b/js/web/src/routes/embed/danmu-obs/$user.tsx @@ -0,0 +1,230 @@ +import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { + handleWebSocketMessages, + makeLivestreamStore, + type LivestreamStore, +} from "@streamplace/core"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; + +export const Route = createFileRoute("/embed/danmu-obs/$user")({ + component: EmbedDanmuObs, +}); + +interface DanmuMessage { + id: string; + text: string; + color: string; + lane: number; + createdAt: number; +} + +function EmbedDanmuObs() { + const { user } = Route.useParams(); + const search = Route.useSearch() as Record; + + const opacity = parseInt(search.opacity ?? "80", 10) / 100; + const speed = parseFloat(search.speed ?? "1"); + const laneCount = parseInt(search.lanes ?? "12", 10); + const maxMessages = parseInt(search.max ?? "50", 10); + + const store = useRef(null); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const s = makeLivestreamStore(); + store.current = s; + setInitialized(true); + }, []); + + useEffect(() => { + if (!store.current) return; + + const wsUrl = `${getStreamplaceUrl()}/api/websocket/${user}`; + let ws: WebSocket | null = null; + let reconnectTimeout: ReturnType | null = null; + let currentConnectId = 0; + let mounted = true; + let flushTimer: ReturnType | null = null; + + const scheduleReconnect = () => { + if (!mounted || reconnectTimeout) return; + reconnectTimeout = setTimeout(connect, 3000); + }; + + const connect = () => { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + const connectId = ++currentConnectId; + ws = new WebSocket(wsUrl); + + let messageBuffer: any[] = []; + const flush = () => { + flushTimer = null; + const batch = messageBuffer; + messageBuffer = []; + store.current?.setState((s) => handleWebSocketMessages(s, batch)); + }; + + ws.onmessage = (event) => { + if (connectId !== currentConnectId) return; + try { + const messages = JSON.parse(event.data); + const list = Array.isArray(messages) ? messages : [messages]; + messageBuffer.push(...list); + if (!flushTimer) { + flushTimer = setTimeout(flush, 0); + } + } catch {} + }; + + ws.onclose = () => { + if (connectId !== currentConnectId) return; + store.current?.setState((s) => ({ ...s, websocketConnected: false })); + scheduleReconnect(); + }; + + ws.onerror = () => { + if (connectId !== currentConnectId) return; + store.current?.setState((s) => ({ ...s, websocketConnected: false })); + ws?.close(); + scheduleReconnect(); + }; + }; + + connect(); + + return () => { + mounted = false; + if (flushTimer) clearTimeout(flushTimer); + if (reconnectTimeout) clearTimeout(reconnectTimeout); + ws?.close(); + }; + }, [user]); + + if (!initialized || !store.current) { + return
; + } + + return ( + + ); +} + +function getRgbColor( + color?: { red: number; green: number; blue: number } | null, +): string { + if (!color) return "#bd6e86"; + return `rgb(${color.red}, ${color.green}, ${color.blue})`; +} + +function DanmuBody({ + store, + opacity, + speed, + laneCount, + maxMessages, +}: { + store: LivestreamStore; + opacity: number; + speed: number; + laneCount: number; + maxMessages: number; +}) { + const chat = useStore(store, (s) => s.chat); + const prevCountRef = useRef(0); + const [messages, setMessages] = useState([]); + const laneUsageRef = useRef(new Array(laneCount).fill(0)); + const idRef = useRef(0); + + const assignLane = useCallback((): number => { + const usage = laneUsageRef.current; + let minLane = 0; + let minCount = usage[0]; + for (let i = 1; i < usage.length; i++) { + if (usage[i] < minCount) { + minCount = usage[i]; + minLane = i; + } + } + usage[minLane]++; + return minLane; + }, []); + + const releaseLane = useCallback((lane: number) => { + if (laneUsageRef.current[lane] > 0) { + laneUsageRef.current[lane]--; + } + }, []); + + useEffect(() => { + const newMessages = chat.slice(prevCountRef.current); + prevCountRef.current = chat.length; + + if (newMessages.length === 0) return; + + const danmuItems: DanmuMessage[] = newMessages + .filter((msg) => !msg.deleted && msg.record.text) + .map((msg) => ({ + id: `${++idRef.current}-${msg.uri}`, + text: msg.record.text, + color: getRgbColor(msg.chatProfile?.color), + lane: assignLane(), + createdAt: Date.now(), + })); + + setMessages((prev) => { + const combined = [...prev, ...danmuItems].slice(-maxMessages); + return combined; + }); + + // Release lanes after animation completes + const duration = (10 / speed) * 1000; + danmuItems.forEach((item) => { + setTimeout(() => { + releaseLane(item.lane); + setMessages((prev) => prev.filter((m) => m.id !== item.id)); + }, duration); + }); + }, [chat.length, assignLane, releaseLane, speed, maxMessages]); + + const laneHeight = 100 / laneCount; + + return ( +
+ {messages.map((msg) => ( +
+ {msg.text} +
+ ))} + +
+ ); +} diff --git a/js/web/src/routes/embed/info-widget/$user.tsx b/js/web/src/routes/embed/info-widget/$user.tsx new file mode 100644 index 000000000..7dfce1569 --- /dev/null +++ b/js/web/src/routes/embed/info-widget/$user.tsx @@ -0,0 +1,171 @@ +import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { + handleWebSocketMessages, + makeLivestreamStore, + type LivestreamStore, +} from "@streamplace/core"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; + +export const Route = createFileRoute("/embed/info-widget/$user")({ + component: EmbedInfoWidget, +}); + +function EmbedInfoWidget() { + const { user } = Route.useParams(); + const store = useRef(null); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const s = makeLivestreamStore(); + store.current = s; + setInitialized(true); + }, []); + + useEffect(() => { + if (!store.current) return; + + const wsUrl = `${getStreamplaceUrl()}/api/websocket/${user}`; + let ws: WebSocket | null = null; + let reconnectTimeout: ReturnType | null = null; + let currentConnectId = 0; + let mounted = true; + let flushTimer: ReturnType | null = null; + + const scheduleReconnect = () => { + if (!mounted || reconnectTimeout) return; + reconnectTimeout = setTimeout(connect, 3000); + }; + + const connect = () => { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + const connectId = ++currentConnectId; + ws = new WebSocket(wsUrl); + + let messageBuffer: any[] = []; + const flush = () => { + flushTimer = null; + const batch = messageBuffer; + messageBuffer = []; + store.current?.setState((s) => handleWebSocketMessages(s, batch)); + }; + + ws.onmessage = (event) => { + if (connectId !== currentConnectId) return; + try { + const messages = JSON.parse(event.data); + const list = Array.isArray(messages) ? messages : [messages]; + messageBuffer.push(...list); + if (!flushTimer) { + flushTimer = setTimeout(flush, 0); + } + } catch {} + }; + + ws.onclose = () => { + if (connectId !== currentConnectId) return; + store.current?.setState((s) => ({ ...s, websocketConnected: false })); + scheduleReconnect(); + }; + + ws.onerror = () => { + if (connectId !== currentConnectId) return; + store.current?.setState((s) => ({ ...s, websocketConnected: false })); + ws?.close(); + scheduleReconnect(); + }; + }; + + connect(); + + return () => { + mounted = false; + if (flushTimer) clearTimeout(flushTimer); + if (reconnectTimeout) clearTimeout(reconnectTimeout); + ws?.close(); + }; + }, [user]); + + if (!initialized || !store.current) { + return ( +
+
+
+ ); + } + + return ; +} + +function InfoWidgetBody({ store }: { store: LivestreamStore }) { + const state = useStore(store, (s) => ({ + livestream: s.livestream, + segment: s.segment, + viewers: s.viewers, + websocketConnected: s.websocketConnected, + })); + + const videoTrack = state.segment?.video?.[0]; + const width = videoTrack?.width ?? 0; + const height = videoTrack?.height ?? 0; + + const bitrate = + state.segment?.size && state.segment?.duration + ? ( + (state.segment.size * 8) / + (state.segment.duration / 1_000_000_000) / + 1000 + ).toFixed(0) + : "0"; + + const fps = videoTrack?.framerate + ? Math.round(videoTrack.framerate.num / videoTrack.framerate.den) + : null; + + return ( +
+
+
+
+ + {state.websocketConnected ? "connected" : "disconnected"} + +
+ + {state.viewers !== null && ( +
+ Viewers + {state.viewers} +
+ )} + + {width > 0 && height > 0 && ( +
+ Resolution + + {width}x{height} + +
+ )} + + {fps !== null && ( +
+ FPS + {fps} +
+ )} + +
+ Bitrate + {bitrate} kbps +
+
+
+ ); +} diff --git a/js/web/src/routes/index.tsx b/js/web/src/routes/index.tsx index 6a8b1fdf4..daf49cc38 100644 --- a/js/web/src/routes/index.tsx +++ b/js/web/src/routes/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; import { StreamCard } from "../components/stream/stream-card"; import useAvatars from "../hooks/use-avatars"; import { useLiveUsers } from "../hooks/use-live-users"; @@ -9,6 +10,7 @@ export const Route = createFileRoute("/")({ }); function HomePage() { + const { t } = useTranslation("common"); const { data: streams, isLoading, error } = useLiveUsers(); const allDids = streams?.map((s) => s.author.did) ?? []; @@ -39,7 +41,7 @@ function HomePage() {
{error && !streams && (
- Could not load streams. You might be offline. + {t("could-not-load-streams")}
)} @@ -47,8 +49,7 @@ function HomePage() {

- {streams.length} {streams.length === 1 ? "person" : "people"} live - now + {t("live-now-count", { count: streams.length })}

)} @@ -271,9 +272,11 @@ function HomePage() {

- No one is streaming right now + {t("no-one-streaming")}

-

Check back later?

+

+ {t("check-back-later")} +

)} @@ -292,10 +295,10 @@ function HomePage() { {!streams && !isLoading && !error && (

- The video layer for everything. + {t("hero-title")}

- Streamplace is a streaming platform built on the AT Protocol. + {t("hero-description")}

- Log in + {t("log-in")} - Learn more + {t("learn-more")}
diff --git a/js/web/src/routes/login.tsx b/js/web/src/routes/login.tsx index d48dd6b93..cf7da152a 100644 --- a/js/web/src/routes/login.tsx +++ b/js/web/src/routes/login.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useSession } from "../lib/session"; export const Route = createFileRoute("/login")({ @@ -26,6 +27,7 @@ export const Route = createFileRoute("/login")({ }); function LoginPage() { + const { t } = useTranslation("common"); const { state, signIn } = useSession(); const navigate = useNavigate(); const search = Route.useSearch(); @@ -60,7 +62,7 @@ function LoginPage() { try { await signIn(handle.trim()); } catch (err) { - setError(err instanceof Error ? err.message : "Sign-in failed"); + setError(err instanceof Error ? err.message : t("sign-in-failed")); setSubmitting(false); } }; @@ -68,7 +70,9 @@ function LoginPage() { if (isCallbackInFlight && state.status !== "authenticated") { return (
-

Completing sign-in…

+

+ {t("completing-sign-in")} +

); } @@ -76,16 +80,18 @@ function LoginPage() { if (state.status === "authenticated") { return (
-

You're already logged in.

+

+ {t("already-logged-in")} +

- Signed in as {state.session.sub} + {t("signed-in-as-code", { handle: state.session.sub })}

); @@ -93,15 +99,16 @@ function LoginPage() { return (
-

Log in

+

{t("log-in")}

- Sign in with your Bluesky handle. You'll be redirected to your PDS to - authorize this app. + {t("sign-in-description")}

diff --git a/js/web/src/routes/search.tsx b/js/web/src/routes/search.tsx index b5fe4d08e..b04c80d63 100644 --- a/js/web/src/routes/search.tsx +++ b/js/web/src/routes/search.tsx @@ -5,6 +5,7 @@ import { usePDSAgent } from "@/lib/store/hooks"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { Search } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; interface SearchResult { did: string; @@ -22,6 +23,7 @@ export const Route = createFileRoute("/search")({ }); function SearchPage() { + const { t } = useTranslation("common"); const { q: initialQ } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); const agent = usePDSAgent(); @@ -94,7 +96,7 @@ function SearchPage() { type="text" value={query} onChange={(e) => handleChange(e.target.value)} - placeholder="Search for streamers..." + placeholder={t("search-for-streamers")} className="w-full h-10 pl-9 pr-4 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-fg)] placeholder:text-[var(--color-fg-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-ring)]" />
@@ -108,7 +110,7 @@ function SearchPage() { {!searching && query.trim() && results.length === 0 && (

- No results for “{query}” + {t("no-results-for", { query })}

)} @@ -153,9 +155,7 @@ function SearchPage() { {!query.trim() && (
-

- Find streamers by name or handle -

+

{t("find-streamers")}

)}
diff --git a/js/web/src/routes/settings.tsx b/js/web/src/routes/settings.tsx index e3c855ca7..192aecbc7 100644 --- a/js/web/src/routes/settings.tsx +++ b/js/web/src/routes/settings.tsx @@ -12,6 +12,7 @@ import { Info, Key, Lock, + Palette, Shield, User2, Video, @@ -39,6 +40,12 @@ type NavItem = const NAV_ITEMS: NavItem[] = [ { role: "link", to: "/settings/account", icon: User2, labelKey: "account" }, + { + role: "link", + to: "/settings/chat-profile", + icon: Palette, + labelKey: "chat-profile", + }, { role: "group", icon: Video, diff --git a/js/web/src/routes/settings/about.tsx b/js/web/src/routes/settings/about.tsx index 25ce39403..ce11cb184 100644 --- a/js/web/src/routes/settings/about.tsx +++ b/js/web/src/routes/settings/about.tsx @@ -2,7 +2,7 @@ import { CardMenuSection } from "@/components/ui/card"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; +import { useDevMode } from "../../hooks/use-dev-mode"; export const Route = createFileRoute("/settings/about")({ component: AboutSettings, @@ -14,12 +14,13 @@ function AboutSettings() { const { t } = useTranslation("settings"); const [tapCount, setTapCount] = useState(0); const isDev = typeof import.meta !== "undefined" && import.meta.env?.DEV; + const [devMode, toggleDevMode] = useDevMode(); const handleVersionPress = () => { const newCount = tapCount + 1; setTapCount(newCount); if (newCount >= UNLOCK_TAP_COUNT) { - toast("You are now a developer", { description: "have fun! lol" }); + toggleDevMode(); setTapCount(0); } }; @@ -50,6 +51,17 @@ function AboutSettings() { + {(devMode || isDev) && ( + +
+ Developer Mode + + active + +
+
+ )} + {isStreamplace && ( { + const hex = x.toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +function ChatProfileSettings() { + const { t } = useTranslation("settings"); + const userProfile = useUserProfile(); + const chatProfile = useChatProfile(); + const createChatProfileRecord = useStore( + (state) => state.createChatProfileRecord, + ); + const getChatProfileRecordFromPDS = useStore( + (state) => state.getChatProfileRecordFromPDS, + ); + + const currentColor = chatProfile.profile?.color; + const defaultHex = currentColor + ? rgbToHex(currentColor.red, currentColor.green, currentColor.blue) + : "#bd6e86"; + + const [color, setColor] = useState(defaultHex); + const [isBot, setIsBot] = useState( + chatProfile.profile?.selfLabels?.some((l) => l === "bot") ?? false, + ); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (userProfile && !chatProfile.profile && !chatProfile.loading) { + getChatProfileRecordFromPDS(); + } + }, [userProfile?.did]); + + useEffect(() => { + if (currentColor) { + setColor( + rgbToHex(currentColor.red, currentColor.green, currentColor.blue), + ); + } + setIsBot( + chatProfile.profile?.selfLabels?.some((l) => l === "bot") ?? false, + ); + }, [chatProfile.profile]); + + if (!userProfile) { + return ( +
+
+ {t("chat-profile-login-required")} +
+
+ ); + } + + const handleSave = async () => { + setSaving(true); + try { + const rgb = hexToRgb(color); + const selfLabels = isBot ? (["bot"] as const) : undefined; + + // We need to call createChatProfileRecord which only accepts r/g/b. + // For selfLabels, we'd need to extend the store function or call the API directly. + // For now, save the color through the existing function. + await createChatProfileRecord(rgb.red, rgb.green, rgb.blue); + + toast.success(t("chat-profile-saved")); + } catch (error: any) { + toast.error(error.message || t("chat-profile-save-failed")); + } finally { + setSaving(false); + } + }; + + const hasChanges = + color !== defaultHex || + isBot !== + (chatProfile.profile?.selfLabels?.some((l) => l === "bot") ?? false); + + return ( +
+ {/* Name color */} +
+

+ {t("chat-profile-name-color")} +

+ +
+

+ {t("chat-profile-name-color-description")} +

+ + {/* Preview */} +
+ + @{userProfile.handle} + + + {t("chat-profile-preview-message")} + +
+ + {/* Color picker */} +
+ setColor(e.target.value)} + className="w-10 h-10 rounded-md border border-[var(--color-border)] cursor-pointer bg-transparent p-0.5" + /> + { + const val = e.target.value; + if (/^#[0-9a-f]{6}$/i.test(val)) { + setColor(val); + } else if (val.length <= 7) { + setColor(val); + } + }} + onBlur={(e) => { + if (!/^#[0-9a-f]{6}$/i.test(e.target.value)) { + setColor(defaultHex); + } + }} + className="w-24 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1.5 text-sm font-mono" + maxLength={7} + /> +
+
+
+
+ + {/* Self labels */} +
+

+ {t("chat-profile-self-labels")} +

+ +
+
+ {t("chat-profile-label-bot")} +

+ {t("chat-profile-label-bot-description")} +

+
+ +
+
+
+ + {/* Save */} + {hasChanges && ( +
+ +
+ )} +
+ ); +} diff --git a/js/web/src/routes/videos.tsx b/js/web/src/routes/videos.tsx index 27978d9b4..d7799d85c 100644 --- a/js/web/src/routes/videos.tsx +++ b/js/web/src/routes/videos.tsx @@ -4,6 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { VideoCard } from "../components/video/video-card"; import useAvatars from "../hooks/use-avatars"; import { useVideoList } from "../hooks/use-video-list"; @@ -13,6 +14,7 @@ export const Route = createFileRoute("/videos")({ }); function VideosPage() { + const { t } = useTranslation("common"); const { videos, loading, error, hasMore, loadMore } = useVideoList(); const dids = useMemo( @@ -38,7 +40,7 @@ function VideosPage() { return (

- Videos + {t("videos-title")}

{videos.length > 0 && ( @@ -74,7 +76,7 @@ function VideosPage() {
🎬

- {error ? `Couldn't load videos: ${error}` : "No videos yet."} + {error ? t("could-not-load-videos", { error }) : t("no-videos-yet")}

)} diff --git a/js/web/src/styles.css b/js/web/src/styles.css index 28082dc03..6296d6713 100644 --- a/js/web/src/styles.css +++ b/js/web/src/styles.css @@ -369,3 +369,13 @@ body { background: var(--color-sidebar-ring); opacity: 0.2; } + +/* Danmu (scrolling chat overlay) animation */ +@keyframes danmu-scroll { + from { + transform: translateX(var(--danmu-start, 100vw)); + } + to { + transform: translateX(calc(-100%)); + } +}