diff --git a/assets/translations/en.json b/assets/translations/en.json
index d755ed588..6bf1c245d 100644
--- a/assets/translations/en.json
+++ b/assets/translations/en.json
@@ -124,6 +124,9 @@
"ttsAlertText": "The automatic announcement feature does not work in silent mode or when the Internet is not connected. Please be careful.",
"bgTtsAlertText": "When the background audio feature is enabled, sound will play even when silent mode is on. Please be especially mindful when on a train.",
"bgTtsAppClipAlertText": "To use the background audio feature, please download the full version.",
+ "ttsFeatureDisabledText": "The automatic announcement feature is temporarily unavailable. Please check the Service Status page below for the latest availability.",
+ "serviceStatus": "Service Status",
+ "failedToOpenLink": "Failed to open the link. Please try again later.",
"weekdayNotice": "Weekday transit stations are included. The corresponding station will be displayed as a passage. Please be careful.",
"holidayNotice": "Includes holiday transit stations. The corresponding station will be displayed as a passage. Please Be careful.",
"shareNotice": "Press and hold the screen for easy sharing😊.",
diff --git a/assets/translations/ja.json b/assets/translations/ja.json
index 1447780ba..335f26277 100644
--- a/assets/translations/ja.json
+++ b/assets/translations/ja.json
@@ -125,6 +125,9 @@
"ttsAlertText": "自動アナウンス機能はマナーモードもしくはインターネット未接続状態では動作いたしません。ご注意ください。",
"bgTtsAlertText": "バックグラウンド音声機能を有効にすると、マナーモード設定中でも音声が流れます。特に電車内ではご注意ください。",
"bgTtsAppClipAlertText": "バックグラウンド音声機能をご利用になるには完全版をダウンロードしてください。",
+ "ttsFeatureDisabledText": "自動アナウンス機能は現在一時的にご利用いただけません。最新の稼働状況は下記のサービスステータスをご確認ください。",
+ "serviceStatus": "サービスステータス",
+ "failedToOpenLink": "リンクを開けませんでした。しばらく経ってからもう一度お試しください。",
"weekdayNotice": "平日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。",
"holidayNotice": "休日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。",
"shareNotice": "画面を長押しすると簡単にシェアができます😊",
diff --git a/docs/state-management.md b/docs/state-management.md
index 1288dff9c..c6d03894a 100644
--- a/docs/state-management.md
+++ b/docs/state-management.md
@@ -104,7 +104,9 @@ const active = useAtomValue(pictureInPictureActiveAtom);
プロファイル時に React DevTools 上でフック単位の再レンダー回数・コストを
計測可能にするため。新しい常駐フックを足すときも同じ形式で追加する
- 設定・プラットフォームで不要になるフックは条件付きマウントで丸ごと止める。
- 実例: `FxTTS` (TTS 有効時のみ)、`FxUpdateLiveActivities` (iOS のみ)、
+ 実例: `FxTTS` (ユーザー設定と Remote Config キルスイッチの両方が有効なときのみ。
+ 単体テストできるよう本体は `src/components/FxTTS.tsx` に定義)、
+ `FxUpdateLiveActivities` (iOS のみ)、
`Permitted.tsx` のウェアラブル連携 (OS 別)。フックを条件分岐で呼ぶことは
できないが、ホストコンポーネントのマウント自体を条件にすれば安全に止められる
diff --git a/src/components/FxTTS.test.tsx b/src/components/FxTTS.test.tsx
new file mode 100644
index 000000000..bb8e40cbd
--- /dev/null
+++ b/src/components/FxTTS.test.tsx
@@ -0,0 +1,110 @@
+import { act, render } from '@testing-library/react-native';
+import { createStore, Provider } from 'jotai';
+import { useTTS } from '~/hooks/useTTS';
+import { isTTSFeatureEnabled } from '~/lib/remoteConfig';
+import speechState from '~/store/atoms/speech';
+import { FxTTS } from './FxTTS';
+
+jest.mock('~/utils/isDevApp', () => ({
+ isDevApp: false,
+}));
+
+jest.mock('~/hooks/useTTS', () => ({
+ useTTS: jest.fn(),
+}));
+
+// useTTSFeatureEnabled(useSyncExternalStore) が購読するリスナーを捕捉し、
+// Remote Config 取得完了(キャッシュ更新)をテストから擬似的に発火できるようにする。
+const mockRemoteConfigListeners = new Set<() => void>();
+jest.mock('~/lib/remoteConfig', () => ({
+ isTTSFeatureEnabled: jest.fn(() => true),
+ subscribeRemoteConfig: jest.fn((listener: () => void) => {
+ mockRemoteConfigListeners.add(listener);
+ return () => {
+ mockRemoteConfigListeners.delete(listener);
+ };
+ }),
+}));
+
+const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled);
+const mockedUseTTS = jest.mocked(useTTS);
+
+const emitRemoteConfigUpdate = () => {
+ act(() => {
+ for (const listener of mockRemoteConfigListeners) {
+ listener();
+ }
+ });
+};
+
+const renderFxTTS = (enabled: boolean) => {
+ const store = createStore();
+ store.set(speechState, {
+ enabled,
+ backgroundEnabled: false,
+ ttsEnabledLanguages: ['JA', 'EN'],
+ monetizedPlanEnabled: false,
+ });
+
+ return render(
+
+
+
+ );
+};
+
+describe('FxTTS', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ mockRemoteConfigListeners.clear();
+ });
+
+ it.each`
+ userEnabled | featureEnabled | shouldMount
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${false} | ${false} | ${false}
+ `(
+ 'ユーザー設定=$userEnabled / Remote Config=$featureEnabled のときマウント=$shouldMount',
+ ({ userEnabled, featureEnabled, shouldMount }) => {
+ mockedIsTTSFeatureEnabled.mockReturnValue(featureEnabled);
+
+ renderFxTTS(userEnabled);
+
+ if (shouldMount) {
+ expect(mockedUseTTS).toHaveBeenCalled();
+ } else {
+ expect(mockedUseTTS).not.toHaveBeenCalled();
+ }
+ }
+ );
+
+ it('マウント後にRemote Configで無効化されるとアンマウントされる', () => {
+ mockedIsTTSFeatureEnabled.mockReturnValue(true);
+
+ renderFxTTS(true);
+
+ expect(mockedUseTTS).toHaveBeenCalled();
+ mockedUseTTS.mockClear();
+
+ // 起動時の非同期取得が後から tts_enabled=false を返したケースを再現する
+ mockedIsTTSFeatureEnabled.mockReturnValue(false);
+ emitRemoteConfigUpdate();
+
+ expect(mockedUseTTS).not.toHaveBeenCalled();
+ });
+
+ it('マウント後にRemote Configで有効化されるとマウントされる', () => {
+ mockedIsTTSFeatureEnabled.mockReturnValue(false);
+
+ renderFxTTS(true);
+
+ expect(mockedUseTTS).not.toHaveBeenCalled();
+
+ mockedIsTTSFeatureEnabled.mockReturnValue(true);
+ emitRemoteConfigUpdate();
+
+ expect(mockedUseTTS).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/FxTTS.tsx b/src/components/FxTTS.tsx
new file mode 100644
index 000000000..e617927e8
--- /dev/null
+++ b/src/components/FxTTS.tsx
@@ -0,0 +1,21 @@
+import { useAtomValue } from 'jotai';
+import type React from 'react';
+import { useTTS } from '~/hooks/useTTS';
+import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled';
+import speechState from '~/store/atoms/speech';
+
+const FxTTSInner: React.FC = () => {
+ useTTS();
+ return null;
+};
+
+// TTS無効時は useTTSText のテキスト構築(毎tick約19ms)ごとスキップする。
+// 有効化時にマウントされ直し、行先選択直後と同じ初回発話抑止から始まる。
+// Remote Config のキルスイッチ(tts_enabled=false)時は、ユーザー設定が有効でも
+// 再生を止める。設定画面のトグル表示(無効化)と実挙動を一致させるため。
+// 起動時の非同期取得完了後に false が届いたケースでも購読経由で確実にアンマウントする。
+export const FxTTS: React.FC = () => {
+ const { enabled } = useAtomValue(speechState);
+ const ttsFeatureEnabled = useTTSFeatureEnabled();
+ return enabled && ttsFeatureEnabled ? : null;
+};
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 58d3e9813..e66792c94 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -86,6 +86,7 @@ export { useTransferLinesFromStation } from './useTransferLinesFromStation';
export { useTransitionHeaderState } from './useTransitionHeaderState';
export { useTTS } from './useTTS';
export { useTTSCache } from './useTTSCache';
+export { useTTSFeatureEnabled } from './useTTSFeatureEnabled';
export { useTTSText } from './useTTSText';
export { useTypeWillChange } from './useTypeWillChange';
export { useUnderMaintenance } from './useUnderMaintenance';
diff --git a/src/hooks/useTTSFeatureEnabled.ts b/src/hooks/useTTSFeatureEnabled.ts
new file mode 100644
index 000000000..df7ae7edc
--- /dev/null
+++ b/src/hooks/useTTSFeatureEnabled.ts
@@ -0,0 +1,9 @@
+import { useSyncExternalStore } from 'react';
+import { isTTSFeatureEnabled, subscribeRemoteConfig } from '~/lib/remoteConfig';
+
+// TTS機能キルスイッチ(tts_enabled)のリアクティブ版。setupRemoteConfig は起動時に
+// 非同期で完了するため、同期読みだけではコールドスタート時にフォールバック(true)で
+// 描画された後、false 到着時の再レンダーが保証されない。キャッシュ更新を購読して
+// 確実に再評価させる。
+export const useTTSFeatureEnabled = (): boolean =>
+ useSyncExternalStore(subscribeRemoteConfig, isTTSFeatureEnabled);
diff --git a/src/lib/remoteConfig.test.ts b/src/lib/remoteConfig.test.ts
index c97d57a76..05445bdf1 100644
--- a/src/lib/remoteConfig.test.ts
+++ b/src/lib/remoteConfig.test.ts
@@ -5,8 +5,10 @@ import {
getMaxPermitAccuracy,
isEtaAssistEnabled,
isForceNotArrivedOnLowAccuracyEnabled,
+ isTTSFeatureEnabled,
resetRemoteConfigCache,
setupRemoteConfig,
+ subscribeRemoteConfig,
} from './remoteConfig';
jest.mock('./workerApi', () => ({
@@ -164,6 +166,62 @@ describe('getEtaFallbackMaxDurationMin', () => {
});
});
+describe('isTTSFeatureEnabled(サーバー側キルスイッチ)', () => {
+ it('falls back to true before setup', () => {
+ expect(isTTSFeatureEnabled()).toBe(true);
+ });
+
+ it('returns the remote boolean after setup', async () => {
+ mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
+ await setupRemoteConfig();
+ expect(isTTSFeatureEnabled()).toBe(false);
+ });
+
+ it('RemoteがONなら有効', async () => {
+ mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: true });
+ await setupRemoteConfig();
+ expect(isTTSFeatureEnabled()).toBe(true);
+ });
+
+ it('falls back to true when the boolean is missing', async () => {
+ mockRemoteConfig({ max_permit_accuracy: 1500 });
+ await setupRemoteConfig();
+ expect(isTTSFeatureEnabled()).toBe(true);
+ });
+
+ it('falls back to true when fetching remote config fails', async () => {
+ mockRemoteConfig({}, false);
+ await expect(setupRemoteConfig()).rejects.toThrow(
+ 'remote config fetch failed: 503'
+ );
+ expect(isTTSFeatureEnabled()).toBe(true);
+ });
+});
+
+describe('subscribeRemoteConfig(キャッシュ更新の購読)', () => {
+ it('setupRemoteConfig 完了時にリスナーへ通知される', async () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeRemoteConfig(listener);
+
+ mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
+ await setupRemoteConfig();
+
+ expect(listener).toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it('購読解除後は通知されない', async () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeRemoteConfig(listener);
+ unsubscribe();
+
+ mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
+ await setupRemoteConfig();
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
+
describe('setupRemoteConfig', () => {
it('fetches the remote config endpoint', async () => {
mockRemoteConfig({
diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts
index 88bf5fe49..c62d5b500 100644
--- a/src/lib/remoteConfig.ts
+++ b/src/lib/remoteConfig.ts
@@ -16,6 +16,9 @@ export const REMOTE_CONFIG_KEYS = {
// ETAフォールバックを継続してよい最大時間(分)。GPS喪失がこれを超えて続く場合は
// フォールバックを打ち切り、不確実な推定に依存し続けないようにする。
ETA_FALLBACK_MAX_DURATION_MIN: 'eta_fallback_max_duration_min',
+ // TTS(自動アナウンス)機能の有効/無効。false のとき設定画面のTTSトグルを無効化し、
+ // 音声合成バックエンド障害時などにサーバー側から機能を止められるようにする。
+ TTS_ENABLED: 'tts_enabled',
} as const;
type RemoteConfigResponse = {
@@ -24,6 +27,7 @@ type RemoteConfigResponse = {
eta_assist_enabled?: boolean;
eta_fallback_arrival_confirm_margin_sec?: number;
eta_fallback_max_duration_min?: number;
+ tts_enabled?: boolean;
};
// 精度超過時に到着判定を未到着へ強制する機能のフォールバック既定値。
@@ -37,6 +41,10 @@ const ETA_FALLBACK_ARRIVAL_CONFIRM_MARGIN_SEC_FALLBACK = 30;
// ETAフォールバックを継続してよい最大時間(分)のフォールバック既定値。
const ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK = 30;
+// TTS機能のフォールバック既定値。キルスイッチ用途のため、未配信・取得失敗時は
+// 既存挙動(利用可能)を維持する true をフォールバックとする。
+const TTS_ENABLED_FALLBACK = true;
+
// リモート設定の数値は「有限かつ正」のみ受理する(0・負値・非数はフォールバックへ倒す)。
// 真偽値や配列は Number() で 1 や 5 に化けるため、number 型に限定してから検証する。
const parsePositiveFiniteNumber = (value: unknown): number | null => {
@@ -54,6 +62,26 @@ let cachedForceNotArrivedEnabled: boolean | null = null;
let cachedEtaAssistEnabled: boolean | null = null;
let cachedEtaFallbackArrivalConfirmMarginSec: number | null = null;
let cachedEtaFallbackMaxDurationMin: number | null = null;
+let cachedTTSEnabled: boolean | null = null;
+
+// setupRemoteConfig は起動時に非同期で完了するため、初回レンダー後にキャッシュが
+// 更新されても React は再レンダーしない。UI(FxTTS・設定画面)が useSyncExternalStore
+// 経由でキャッシュ更新へ追従できるよう、変更通知のリスナーを提供する。
+const remoteConfigListeners = new Set<() => void>();
+
+// キャッシュ更新をUIへ購読させる。戻り値は購読解除関数(useSyncExternalStore 互換)。
+export const subscribeRemoteConfig = (listener: () => void): (() => void) => {
+ remoteConfigListeners.add(listener);
+ return () => {
+ remoteConfigListeners.delete(listener);
+ };
+};
+
+const notifyRemoteConfigListeners = (): void => {
+ for (const listener of remoteConfigListeners) {
+ listener();
+ }
+};
// テスト用および値の再取得時にキャッシュを破棄する。
export const resetRemoteConfigCache = (): void => {
@@ -62,6 +90,8 @@ export const resetRemoteConfigCache = (): void => {
cachedEtaAssistEnabled = null;
cachedEtaFallbackArrivalConfirmMarginSec = null;
cachedEtaFallbackMaxDurationMin = null;
+ cachedTTSEnabled = null;
+ notifyRemoteConfigListeners();
};
// 起動時に一度だけ Worker からリモート設定を取得しキャッシュへ格納する。
@@ -97,6 +127,10 @@ export const setupRemoteConfig = async (): Promise => {
if (maxDurationMin != null) {
cachedEtaFallbackMaxDurationMin = maxDurationMin;
}
+ if (typeof data.tts_enabled === 'boolean') {
+ cachedTTSEnabled = data.tts_enabled;
+ }
+ notifyRemoteConfigListeners();
};
// 最大許容精度(m)を同期的に取得する。setupRemoteConfig 完了後は取得済みの
@@ -150,3 +184,13 @@ export const getEtaFallbackMaxDurationMin = (): number => {
}
return ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK;
};
+
+// TTS(自動アナウンス)機能の有効/無効を同期的に取得する。setupRemoteConfig 完了後は
+// 取得済みのリモート値を、未設定・取得失敗時はフォールバック(true=利用可能)を返す。
+// false のとき設定画面のTTSトグルは無効化される(サーバー側キルスイッチ)。
+export const isTTSFeatureEnabled = (): boolean => {
+ if (cachedTTSEnabled != null) {
+ return cachedTTSEnabled;
+ }
+ return TTS_ENABLED_FALLBACK;
+};
diff --git a/src/screens/Main.tsx b/src/screens/Main.tsx
index e6b2f2dc0..405d1162d 100644
--- a/src/screens/Main.tsx
+++ b/src/screens/Main.tsx
@@ -27,6 +27,7 @@ import {
} from '~/@types/graphql';
import AndroidPictureInPictureView from '~/components/AndroidPictureInPictureView';
import DevOverlay from '~/components/DevOverlay';
+import { FxTTS } from '~/components/FxTTS';
import Header from '~/components/Header';
import { SelectBoundModal } from '~/components/SelectBoundModal';
import { STORAGE_KEYS } from '~/constants';
@@ -52,7 +53,6 @@ import {
useTelemetrySender,
useTransferLines,
useTransitionHeaderState,
- useTTS,
useTypeWillChange,
useUpdateBottomState,
useUpdateLiveActivities,
@@ -83,7 +83,6 @@ import navigationState, {
leftStationsAtom,
} from '../store/atoms/navigation';
import { pictureInPictureActiveAtom } from '../store/atoms/pictureInPicture';
-import speechState from '../store/atoms/speech';
import stationState, {
arrivedAtom,
selectedDirectionAtom,
@@ -167,16 +166,6 @@ const FxStartBackgroundLocationUpdates: React.FC = () => {
useStartBackgroundLocationUpdates();
return null;
};
-const FxTTSInner: React.FC = () => {
- useTTS();
- return null;
-};
-// TTS無効時は useTTSText のテキスト構築(毎tick約19ms)ごとスキップする。
-// 有効化時にマウントされ直し、行先選択直後と同じ初回発話抑止から始まる。
-const FxTTS: React.FC = () => {
- const { enabled } = useAtomValue(speechState);
- return enabled ? : null;
-};
const FxUpdateLiveActivitiesInner: React.FC = () => {
useUpdateLiveActivities();
return null;
diff --git a/src/screens/TTSSettings.test.tsx b/src/screens/TTSSettings.test.tsx
index 3f9851ff8..cdc6abf83 100644
--- a/src/screens/TTSSettings.test.tsx
+++ b/src/screens/TTSSettings.test.tsx
@@ -1,6 +1,8 @@
-import { fireEvent, render } from '@testing-library/react-native';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { createStore, Provider } from 'jotai';
-import { STORAGE_KEYS } from '~/constants';
+import { Alert, Linking } from 'react-native';
+import { STATUS_URL, STORAGE_KEYS } from '~/constants';
+import { isTTSFeatureEnabled } from '~/lib/remoteConfig';
import { storage } from '~/lib/storage';
import speechState, { type StationState } from '~/store/atoms/speech';
import TTSSettingsScreen from './TTSSettings';
@@ -9,6 +11,13 @@ jest.mock('~/utils/isDevApp', () => ({
isDevApp: false,
}));
+jest.mock('~/lib/remoteConfig', () => ({
+ isTTSFeatureEnabled: jest.fn(() => true),
+ subscribeRemoteConfig: jest.fn(() => () => {}),
+}));
+
+const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled);
+
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
goBack: jest.fn(),
@@ -48,6 +57,10 @@ const renderWithSpeechState = (speech: Partial) => {
};
describe('TTSSettingsScreen', () => {
+ beforeEach(() => {
+ mockedIsTTSFeatureEnabled.mockReturnValue(true);
+ });
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -89,4 +102,81 @@ describe('TTSSettingsScreen', () => {
expect(store.get(speechState).ttsEnabledLanguages).toEqual(['JA', 'EN']);
expect(storage.contains(STORAGE_KEYS.TTS_ENABLED_LANGUAGES)).toBe(false);
});
+
+ describe('feature flag(tts_enabled)がfalseの場合', () => {
+ beforeEach(() => {
+ mockedIsTTSFeatureEnabled.mockReturnValue(false);
+ });
+
+ it('TTSトグルをOFF表示かつ無効化し設定を変更できない', () => {
+ const { getByLabelText, store } = renderWithSpeechState({
+ enabled: true,
+ });
+
+ const ttsToggle = getByLabelText('toEnabled');
+ expect(ttsToggle.props.accessibilityState).toMatchObject({
+ checked: false,
+ disabled: true,
+ });
+
+ fireEvent.press(ttsToggle);
+
+ // 保存済みのユーザー設定自体は破棄しない(フラグ復帰時に元へ戻る)
+ expect(store.get(speechState).enabled).toBe(true);
+ expect(storage.contains(STORAGE_KEYS.SPEECH_ENABLED)).toBe(false);
+ });
+
+ it('バックグラウンド再生・言語トグルも無効化される', () => {
+ const { getByLabelText } = renderWithSpeechState({
+ enabled: true,
+ backgroundEnabled: true,
+ });
+
+ expect(
+ getByLabelText('autoAnnounceBackgroundTitle').props.accessibilityState
+ ).toMatchObject({ checked: false, disabled: true });
+ expect(getByLabelText('japanese').props.accessibilityState).toMatchObject(
+ { disabled: true }
+ );
+ expect(getByLabelText('english').props.accessibilityState).toMatchObject({
+ disabled: true,
+ });
+ });
+
+ it('利用不可の説明とサービスステータスリンクを表示しタップでブラウザを開く', () => {
+ const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true);
+
+ const { getByText } = renderWithSpeechState({ enabled: true });
+
+ expect(getByText('ttsFeatureDisabledText')).toBeTruthy();
+
+ fireEvent.press(getByText('serviceStatus'));
+
+ expect(openURLSpy).toHaveBeenCalledWith(STATUS_URL);
+ });
+
+ it('サービスステータスリンクを開けなかった場合はエラーAlertを表示する', async () => {
+ jest
+ .spyOn(Linking, 'openURL')
+ .mockRejectedValue(new Error('cannot open'));
+ const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation();
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation();
+
+ const { getByText } = renderWithSpeechState({ enabled: true });
+
+ fireEvent.press(getByText('serviceStatus'));
+
+ await waitFor(() => {
+ expect(alertSpy).toHaveBeenCalledWith('errorTitle', 'failedToOpenLink');
+ });
+ expect(errorSpy).toHaveBeenCalled();
+ });
+ });
+
+ it('feature flagがtrueの場合はサービスステータスリンクを表示しない', () => {
+ const { queryByText } = renderWithSpeechState({ enabled: true });
+
+ expect(queryByText('ttsFeatureDisabledText')).toBeNull();
+ expect(queryByText('serviceStatus')).toBeNull();
+ });
});
diff --git a/src/screens/TTSSettings.tsx b/src/screens/TTSSettings.tsx
index 57e2d81d5..680bee4c9 100644
--- a/src/screens/TTSSettings.tsx
+++ b/src/screens/TTSSettings.tsx
@@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Alert,
type GestureResponderEvent,
+ Linking,
Pressable,
Animated as RNAnimated,
StyleSheet,
@@ -15,10 +16,11 @@ import FooterTabBar from '~/components/FooterTabBar';
import { SettingsHeader } from '~/components/SettingsHeader';
import { StatePanel } from '~/components/ToggleButton';
import Typography from '~/components/Typography';
+import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled';
import speechState from '~/store/atoms/speech';
import { isLEDThemeAtom } from '~/store/atoms/theme';
import { translate } from '~/translation';
-import { STORAGE_KEYS } from '../constants';
+import { STATUS_URL, STORAGE_KEYS } from '../constants';
import { storage } from '../lib/storage';
type SettingItem = {
@@ -100,13 +102,17 @@ const ListFooter = ({
ttsLanguageItems,
ttsEnabledLanguages,
speechEnabled,
+ ttsFeatureEnabled,
onToggleTTSLanguage,
+ onPressServiceStatus,
onPressOK,
}: {
ttsLanguageItems: TTSLanguageSettingItem[];
ttsEnabledLanguages: TTSLanguage[];
speechEnabled: boolean;
+ ttsFeatureEnabled: boolean;
onToggleTTSLanguage: (language: TTSLanguage) => void;
+ onPressServiceStatus: () => void;
onPressOK: () => void;
}) => (
<>
@@ -140,6 +146,31 @@ const ListFooter = ({
>
{translate('requireJapaneseOrEnglish')}
+ {!ttsFeatureEnabled ? (
+ <>
+
+ {translate('ttsFeatureDisabledText')}
+
+
+ {translate('serviceStatus')}
+
+ >
+ ) : null}