diff --git a/App-plans.md b/App-plans.md
new file mode 100644
index 0000000..4dc970b
--- /dev/null
+++ b/App-plans.md
@@ -0,0 +1,178 @@
+# App 化規劃建議書
+
+這位係針對「客源翠 HakSpring」網站 App 化个規劃建議,會分幾個部分來討論。
+
+## 總體建議
+
+考量到這隻網站目前係用 Vanilla JavaScript 開發,而且有「離線優先」个設計,這表示大部分个功能都做在前端,這對 App 化非常有幫助。
+
+## 1. 用 Tauri 來開發?
+
+Tauri 係一隻用 Rust 為後端、任何前端框架為介面个跨平台應用程式開發框架。
+
+### 優點
+
+* **性能**: 因為後端係用 Rust,性能比用 Electron (JavaScript 後端) 个方案還較好。
+* **檔案較細**: 打包出來个應用程式比 Electron 細非常多。
+* **安全性**: Tauri 在設計上有較多个安全性考量。
+* **跨平台**: 一次開發做得打包成 Windows, macOS, Linux 应用程式。
+
+### 缺點
+
+* **行動裝置支援**: Tauri 對行動裝置个支援還在實驗階段 (alpha),可能還無恁穩定,而且需要另外設定。
+* **學習曲線**: 若然無熟悉 Rust,後端个部分會需要學習。毋過,若然 App 个主要邏輯還係在前端,就較無這隻問題。
+
+### 結論
+
+若然主要目標係 **桌面應用程式**,Tauri 係一隻非常好个選擇。若然主要目標係 **行動應用程式**,可能愛考慮其他方案,或者等到 Tauri 在行動裝置个支援穩定下來。
+
+---
+
+## 2. 開發 Android App
+
+目前最主流个方法係用 WebView 將現有个網站包起來。
+
+### 步驟
+
+1. **設定開發環境**:
+ * 安裝 [Android Studio](https://developer.android.com/studio)。
+ * 安裝 Java Development Kit (JDK)。
+ * 設定 Android SDK 摎模擬器 (Emulator)。
+2. **建立專案**:
+ * 在 Android Studio 裡肚建立一隻新專案。
+ * 選擇 "Empty Activity" 模板。
+3. **加入 WebView**:
+ * 在 `activity_main.xml` 版面檔案裡肚加入一隻 `WebView` 元件。
+4. **設定 WebView**:
+ * 在 `MainActivity.java` (或 `MainActivity.kt`) 裡肚,設定 WebView 來載入網站个 URL。
+ * 啟用 JavaScript: `webView.getSettings().setJavaScriptEnabled(true);`
+ * 處理離線功能: 因為網站有離線功能,愛確定 WebView 有啟用本地儲存 (DOM Storage) 摎 IndexedDB。
+ ```java
+ // 啟用 DOM Storage API,這係 localStorage 摎 sessionStorage 需要个
+ webView.getSettings().setDomStorageEnabled(true);
+
+ // 啟用 IndexedDB。setDatabaseEnabled() 主要係為著舊版个 Web SQL,這隻 API 目前既經棄用。
+ // IndexedDB 在啟用 JavaScript 後通常就會自動支援。
+ webView.getSettings().setDatabaseEnabled(true);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+ // 這行係為著 API 19 (KitKat) 以前个版本設定 Web SQL 路徑,目前已罕見。
+ webView.getSettings().setDatabasePath("/data/data/" + this.getPackageName() + "/databases/");
+ }
+ ```
+5. **打包 App**:
+ * 產生一隻簽署過个 APK (Android Package) 或 AAB (Android App Bundle)。
+6. **上架**:
+ * 到 [Google Play Console](https://play.google.com/console) 註冊開發者帳號,然後上傳 App。
+
+### 測試環境
+
+* **Android 模擬器**: Android Studio 內建个模擬器做得模擬無共樣个裝置摎 Android 版本。
+* **實體裝置**: 直接用 USB 連到電腦,在實體手機上測試。
+
+---
+
+## 3. 開發 iOS App
+
+同 Android 相像,iOS 也係用 WebView (WKWebView) 來包裝。
+
+### 步驟
+
+1. **設定開發環境**:
+ * 需要一臺運行 macOS 个電腦。
+ * 安裝 [Xcode](https://developer.apple.com/xcode/)。
+ * 註冊 Apple Developer 帳號 (若愛上架到 App Store)。
+2. **建立專案**:
+ * 在 Xcode 裡肚建立一隻新專案。
+ * 選擇 "App" 模板,介面用 "Storyboard"。
+3. **加入 WKWebView**:
+ * 打開 `Main.storyboard`,從元件庫拖一隻 "WebKit View" 到畫面上。
+4. **設定 WKWebView**:
+ * 在 `ViewController.swift` 裡肚,用程式碼控制 WKWebView 來載入網站。
+ * 確定有處理本地資料个權限。iOS 个 WKWebView 預設就會支援 IndexedDB。
+ ```swift
+ import UIKit
+ import WebKit
+
+ class ViewController: UIViewController, WKUIDelegate {
+
+ var webView: WKWebView!
+
+ override func loadView() {
+ let webConfiguration = WKWebViewConfiguration()
+ webView = WKWebView(frame: .zero, configuration: webConfiguration)
+ webView.uiDelegate = self
+ view = webView
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ // 用 guard let 安全地處理 URL,避免因無效 URL 造成閃退
+ guard let myURL = URL(string:"https://gohakka.org/hak-ka-source-sui/") else { // 請換成網站个 URL
+ print("Error: Invalid URL")
+ return
+ }
+
+ let myRequest = URLRequest(url: myURL)
+ webView.load(myRequest)
+ }
+ }
+ ```
+5. **打包 App**:
+ * 用 Xcode 來建置 (Build) 摎歸檔 (Archive) 專案。
+6. **上架**:
+ * 用 "Transporter" app 將建置好个檔案上傳到 [App Store Connect](https://appstoreconnect.apple.com/)。
+
+### 測試環境
+
+* **iOS 模擬器**: Xcode 內建个模擬器,做得模擬無共樣个 iPhone 摎 iPad。
+* **實體裝置**: 需要 Apple Developer 帳號正做得在實體 iPhone 上安裝測試。
+
+---
+
+## 4. 開發桌面 PWA (Progressive Web App)
+
+PWA 係分網站做得像原生 App 一樣安裝到電腦桌面或手機主畫面。這隻網站既經有 `service-worker.js`,表示佢有 PWA 个基礎。
+
+### 必要性
+
+**非常高**。因為:
+
+* **成本最低**: 無需要另外寫程式碼,淨愛確定 `manifest.json` (或相應个設定) 摎 `service-worker.js` 有設定好。
+* **跨平台**: 所有支援个瀏覽器 (Chrome, Edge, Safari) 都做得安裝。
+* **體驗當好**: 使用者做得直接從網站安裝,無需要透過 App 市集。安裝以後就同一般个應用程式共樣。
+
+### 建議步驟
+
+1. **檢查 Service Worker**: 確定 `service-worker.js` 有正確快取所有必要个檔案,提供良好个離線體驗。
+2. **建立 Web App Manifest**: 這隻專案目前無 `manifest.json`,愛加一隻。這隻檔案會定義 App 个名稱、圖示、啟動畫面等。
+ * 建立一隻 `manifest.json` 檔案。
+ * 在 `index.html` 裡肚連結佢:``
+3. **設定 `manifest.json`**:
+ ```json
+ {
+ "short_name": "客源翠",
+ "name": "客源翠 HakSpring",
+ "icons": [
+ {
+ "src": "android-chrome-192x192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "android-chrome-512x512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff"
+ }
+ ```
+4. **提供明確个安裝引導**: 在網站上加一隻按鈕或係說明,教使用者仰般安裝 PWA。
+
+### 總結
+
+PWA 係目前對這隻專案來講,投資報酬率最高个方案。建議優先完成 PWA 个設定,然後正來考慮用 WebView 包裝成 Android 摎 iOS App。若係未來有桌面應用程式个需求,Tauri 會係一隻當好个選擇。
\ No newline at end of file
diff --git a/index.html b/index.html
index d00f37f..d9fc87a 100644
--- a/index.html
+++ b/index.html
@@ -16,6 +16,7 @@
+
diff --git a/info.md b/info.md
index 6294acf..c7bd077 100644
--- a/info.md
+++ b/info.md
@@ -47,6 +47,35 @@
- 空白鍵:開始自動放送最新進度/暫停
- Esc:停止放送
+## 「安裝」客源翠 App
+
+這隻網站係一隻「漸進式網路應用程式 (PWA)」,你做得像安裝普通 App 共樣,將佢加到手機或電腦个主畫面,用起來會還較利便。
+
+### Android (Chrome 瀏覽器)
+1. 用 Chrome 打開客源翠網站。
+2. 點選瀏覽器右上角个「選單」撳鈕 (通常係三隻點)。
+3. 在跳出來个選單裡肚,尋並點選「**安裝應用程式**」或「**加到主畫面**」。
+4. 確認安裝,客源翠个圖示就會出現在你个應用程式列表。
+
+### Android (Firefox 瀏覽器)
+1. 用 Firefox 打開客源翠網站。
+2. 點選瀏覽器右下角或右上角个「選單」撳鈕 (三隻點)。
+3. 尋並點選「**安裝**」。
+4. 確認以後,圖示就會加到主畫面。
+
+### iOS / iPadOS (Safari 瀏覽器)
+雖然 iOS 無完整支援 PWA 个所有功能 (例如離線快取),但係你還係做得將佢加到主畫面,用起來像 App 共樣。
+1. 用 Safari 打開客源翠網站。
+2. 點選下底或頂項工具列个「**分享**」撳鈕 (一個有向上箭頭个四方框)。
+3. 在分享選單裡肚,向下滑動,尋並點選「**加到主畫面**」。
+4. 你做得自家改 App 个名仔,然後點「新增」,圖示就會出現在主畫面上。
+
+### 電腦 (Chrome, Edge, 其他 Chromium 核心瀏覽器)
+1. 用瀏覽器打開客源翠網站。
+2. 在網址列个右片,你會看著一隻有向下箭頭个「**安裝**」圖示。
+3. 點選該隻圖示,然後在跳出來个視窗點「安裝」。
+4. 客源翠就會變成一隻獨立个視窗應用程式,你做得將佢釘在工具列上。
+
## 資料來源
- 認證詞句文字:哈客網路學院 ODS 檔(2024 年度)
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..931f06c
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,22 @@
+{
+ "short_name": "客源翠",
+ "name": "客源翠 HakSpring",
+ "description": "客話詞典、認證詞彙分類放送學習網站",
+ "icons": [
+ {
+ "src": "android-chrome-192x192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "android-chrome-512x512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "scope": "/",
+ "display": "standalone",
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff"
+}
\ No newline at end of file
diff --git a/service-worker.js b/service-worker.js
index 8e3ffbe..400f371 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -1,52 +1,116 @@
// service-worker.js
-const CACHE_NAME = 'audio-cache-v1';
+const STATIC_CACHE_NAME = 'hakspring-static-v2'; // Incremented version
+const AUDIO_CACHE_NAME = 'hakspring-audio-v1';
+const CACHE_VERSION = 2; // Corresponds to static cache version
+
+// App Shell files
+const STATIC_ASSETS = [
+ '/',
+ 'index.html',
+ 'style.css',
+ 'main.js',
+ 'js/romanizer.js',
+ 'manifest.json',
+ 'apple-touch-icon.png',
+ 'favicon-32x32.png',
+ 'favicon-16x16.png',
+ 'android-chrome-192x192.png',
+ 'android-chrome-512x512.png',
+ 'favicon.ico',
+ 'og-image.png',
+ 'empty_category.mp3',
+ 'endOfPlay.mp3',
+ 'info.md',
+ 'whatsnew.md'
+];
// --- Service Worker Lifecycle ---
self.addEventListener('install', (event) => {
- console.log('Service Worker: Installing...');
- // The Service Worker is installed.
- // We don't need to pre-cache anything here, caching will be done on the fly.
- event.waitUntil(self.skipWaiting());
+ console.log(`Service Worker v${CACHE_VERSION}: Installing...`);
+ event.waitUntil(
+ caches.open(STATIC_CACHE_NAME).then((cache) => {
+ console.log('Service Worker: Caching static assets.');
+ return cache.addAll(STATIC_ASSETS);
+ }).then(() => {
+ // Force the waiting service worker to become the active service worker.
+ return self.skipWaiting();
+ })
+ );
});
self.addEventListener('activate', (event) => {
- console.log('Service Worker: Activating...');
- // Clean up old caches if necessary.
- event.waitUntil(self.clients.claim());
+ console.log(`Service Worker v${CACHE_VERSION}: Activating...`);
+ // Clean up old caches.
+ event.waitUntil(
+ caches.keys().then((cacheNames) => {
+ return Promise.all(
+ cacheNames.map((cacheName) => {
+ // Delete caches that are not the current static or audio cache
+ if (cacheName !== STATIC_CACHE_NAME && cacheName !== AUDIO_CACHE_NAME) {
+ console.log('Service Worker: Deleting old cache:', cacheName);
+ return caches.delete(cacheName);
+ }
+ })
+ );
+ }).then(() => {
+ // Tell the active service worker to take control of the page immediately.
+ return self.clients.claim();
+ })
+ );
});
// --- Fetch Event for Caching ---
self.addEventListener('fetch', (event) => {
- // We only want to cache audio files.
- if (event.request.url.endsWith('.mp3')) {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Ignore non-GET requests
+ if (request.method !== 'GET') {
+ return;
+ }
+
+ // Ignore requests to Google Analytics
+ if (url.hostname.includes('googletagmanager.com')) {
+ return;
+ }
+
+ // Strategy for audio files: Cache on demand (cache-first)
+ if (url.pathname.endsWith('.mp3')) {
event.respondWith(
- caches.open(CACHE_NAME).then((cache) => {
- return cache.match(event.request).then((response) => {
- // If the file is already in the cache, serve it.
- if (response) {
- console.log('SW: Serving audio from cache:', event.request.url);
- return response;
+ caches.open(AUDIO_CACHE_NAME).then((cache) => {
+ return cache.match(request).then((cachedResponse) => {
+ if (cachedResponse) {
+ return cachedResponse;
}
- // If not in cache, fetch from the network, cache it, and then return it.
- return fetch(event.request).then((networkResponse) => {
- console.log('SW: Caching new audio file:', event.request.url);
- // We need to clone the response because a response can only be consumed once.
- cache.put(event.request, networkResponse.clone());
+ return fetch(request).then((networkResponse) => {
+ console.log('SW: Caching new audio file:', request.url);
+ cache.put(request, networkResponse.clone());
return networkResponse;
- }).catch(error => {
- console.error('SW: Fetching and caching audio failed:', error);
- // If fetch fails, we can't do much, just let the browser handle the error.
- throw error;
});
});
})
);
- } else {
- // For all other requests, just let the browser handle it.
- event.respondWith(fetch(event.request));
+ return; // Done with audio
}
+
+ // Strategy for all other requests: Cache-first, falling back to network, then caching the new response.
+ event.respondWith(
+ caches.match(request).then((cachedResponse) => {
+ return cachedResponse || fetch(request).then((networkResponse) => {
+ // Check if we received a valid response
+ if (networkResponse && networkResponse.status === 200) {
+ const responseToCache = networkResponse.clone();
+ caches.open(STATIC_CACHE_NAME).then((cache) => {
+ cache.put(request, responseToCache);
+ console.log('SW: Cached new static resource:', request.url);
+ });
+ }
+ return networkResponse;
+ });
+ })
+ );
});
\ No newline at end of file
diff --git a/whatsnew.md b/whatsnew.md
index 43677fc..433519f 100644
--- a/whatsnew.md
+++ b/whatsnew.md
@@ -1,12 +1,10 @@
-
+
[**<<< 回資訊主頁**](#show-info)
# 「客源翠 HakSpring」新消息
-20251009 14:01
-
-### 9/17 進前就有用个人,請改用 [hakspring.pages.dev](https://hakspring.pages.dev/) 網址斯好 lió
-#### 嗨~大自家~客源翠的舊網址 `aiuanyu.github.io` 已經設計打包舊進度過來。而新網址的進度也會受到保護。這項遷移只需要做一次就好,之後大家可以直接用 [hakspring.pages.dev](https://hakspring.pages.dev/) 網址(可加入書籤/最愛或桌面捷徑)~
+20251014
+- [新] 做得直接在 Android、電腦瀏覽器「安裝」客源翠 app(iOS 係同網頁加去主畫面);[相關說明連去](#show-info)
- 2025/10/09 [新] 對舊網址搬徙過來个時節,會保護新進度
- 2025/9/29 [整好] iOS「長按」客話例句个字詞 →「查讀音」正常運作
- (其他平台本旦斯有)