@mulmoclaude/spotify-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Preview.vue.d.ts +15 -0
- package/dist/View.vue.d.ts +3 -0
- package/dist/client.d.ts +49 -0
- package/dist/definition-CfBmxEFr.js +4388 -0
- package/dist/definition-CfBmxEFr.js.map +1 -0
- package/dist/definition.d.ts +76 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/lang/en.d.ts +55 -0
- package/dist/lang/index.d.ts +107 -0
- package/dist/lang/ja.d.ts +55 -0
- package/dist/listening.d.ts +29 -0
- package/dist/normalize.d.ts +18 -0
- package/dist/oauth.d.ts +36 -0
- package/dist/playback.d.ts +30 -0
- package/dist/profile.d.ts +32 -0
- package/dist/schemas.d.ts +135 -0
- package/dist/search.d.ts +19 -0
- package/dist/searchSummary.d.ts +20 -0
- package/dist/style.css +367 -0
- package/dist/time.d.ts +2 -0
- package/dist/tokens.d.ts +19 -0
- package/dist/types.d.ts +170 -0
- package/dist/vue.d.ts +80 -0
- package/dist/vue.js +867 -0
- package/dist/vue.js.map +1 -0
- package/package.json +43 -0
package/dist/vue.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vue.js","names":[],"sources":["../src/lang/en.ts","../src/lang/ja.ts","../src/lang/index.ts","../src/View.vue","../src/View.vue","../src/Preview.vue","../src/Preview.vue","../src/vue.ts"],"sourcesContent":["// English copy for the Spotify plugin View / Preview. Plugin-local\n// i18n — same shape as bookmarks-plugin / todo-plugin.\n\nexport default {\n title: \"Spotify\",\n notConnected: \"Not connected to Spotify\",\n notConfigured: \"Client ID not configured\",\n configureHelp: \"Paste your Spotify Developer Dashboard Client ID and click Save.\",\n configurePlaceholder: \"Spotify Client ID\",\n save: \"Save\",\n saving: \"Saving…\",\n saved: \"Saved.\",\n saveFailed: \"Save failed.\",\n connect: \"Connect Spotify\",\n connecting: \"Opening Spotify consent…\",\n connected: \"Connected.\",\n reconnect: \"Reconnect\",\n disconnect: \"Disconnect\",\n refresh: \"Refresh\",\n setupGuideLink: \"How do I get a Client ID?\",\n scopes: \"Scopes\",\n expiresAt: \"Expires\",\n\n tabLiked: \"Liked\",\n tabPlaylists: \"Playlists\",\n tabRecent: \"Recent\",\n tabNowPlaying: \"Now playing\",\n tabSearch: \"Search\",\n searchPlaceholder: \"Search tracks, artists, albums, playlists\",\n searchSubmit: \"Search\",\n searchHint: \"Type a query and press Search.\",\n searchEmpty: \"No results.\",\n searchTracks: \"Tracks\",\n searchArtists: \"Artists\",\n searchAlbums: \"Albums\",\n searchPlaylists: \"Playlists\",\n\n empty: \"Nothing to show.\",\n emptyLiked: \"You haven't liked any songs yet.\",\n emptyPlaylists: \"No playlists found.\",\n emptyRecent: \"No recently played tracks.\",\n emptyNowPlaying: \"Nothing is playing right now.\",\n\n loading: \"Loading…\",\n loadFailed: \"Failed to load.\",\n retry: \"Retry\",\n\n trackBy: \"by\",\n tracksCount: \"tracks\",\n\n previewSummary: \"Spotify\",\n\n // Player Controls (PR 3)\n playerControls: \"Playback\",\n premiumRequired: \"Spotify Premium is required to control playback. Free / open accounts cannot use these controls; the rest of the plugin still works.\",\n volume: \"Volume\",\n devices: \"Devices\",\n deviceActive: \"active\",\n transferToDevice: \"Transfer here\",\n btnPrevious: \"Previous track\",\n btnPause: \"Pause\",\n btnPlay: \"Play\",\n btnNext: \"Next track\",\n} as const;\n","// 日本語コピー。Plugin-local i18n は en/ja のみ — host の 8 言語\n// 体制とは独立して plugin 自身で必要な分を抱える方針 (todo / bookmarks\n// と同じ)。\n\nexport default {\n title: \"Spotify\",\n notConnected: \"Spotify に未接続です\",\n notConfigured: \"Client ID が未設定です\",\n configureHelp: \"Spotify Developer Dashboard で発行した Client ID を貼り付けて Save を押してください。\",\n configurePlaceholder: \"Spotify Client ID\",\n save: \"保存\",\n saving: \"保存中…\",\n saved: \"保存しました。\",\n saveFailed: \"保存に失敗しました。\",\n connect: \"Spotify に接続\",\n connecting: \"Spotify の同意画面を開きます…\",\n connected: \"接続済み\",\n reconnect: \"再接続\",\n disconnect: \"切断\",\n refresh: \"更新\",\n setupGuideLink: \"Client ID の取得方法\",\n scopes: \"Scope\",\n expiresAt: \"有効期限\",\n\n tabLiked: \"Liked\",\n tabPlaylists: \"Playlists\",\n tabRecent: \"Recent\",\n tabNowPlaying: \"Now playing\",\n tabSearch: \"検索\",\n searchPlaceholder: \"曲・アーティスト・アルバム・プレイリストを検索\",\n searchSubmit: \"検索\",\n searchHint: \"クエリを入力して検索を押してください。\",\n searchEmpty: \"ヒットなし。\",\n searchTracks: \"曲\",\n searchArtists: \"アーティスト\",\n searchAlbums: \"アルバム\",\n searchPlaylists: \"プレイリスト\",\n\n empty: \"表示する項目がありません。\",\n emptyLiked: \"Liked Songs がありません。\",\n emptyPlaylists: \"Playlist が見つかりませんでした。\",\n emptyRecent: \"最近聞いた曲はありません。\",\n emptyNowPlaying: \"現在再生中の曲はありません。\",\n\n loading: \"読み込み中…\",\n loadFailed: \"読み込みに失敗しました。\",\n retry: \"再試行\",\n\n trackBy: \"—\",\n tracksCount: \"曲\",\n\n previewSummary: \"Spotify\",\n\n // Player Controls (PR 3)\n playerControls: \"再生制御\",\n premiumRequired: \"再生制御には Spotify Premium が必要です。Free / Open アカウントでは利用できません。それ以外の機能はそのまま使えます。\",\n volume: \"音量\",\n devices: \"デバイス\",\n deviceActive: \"アクティブ\",\n transferToDevice: \"ここに移す\",\n btnPrevious: \"前の曲\",\n btnPause: \"一時停止\",\n btnPlay: \"再生\",\n btnNext: \"次の曲\",\n} as const;\n","// Plugin-local i18n — bookmarks/todo と同じ pattern。\n\nimport { computed } from \"vue\";\nimport { useRuntime } from \"gui-chat-protocol/vue\";\nimport en from \"./en\";\nimport ja from \"./ja\";\n\nconst MESSAGES = { en, ja } as const;\ntype LocaleKey = keyof typeof MESSAGES;\n\nfunction isSupportedLocale(value: string): value is LocaleKey {\n // `in` walks the prototype chain so `\"toString\" in MESSAGES`\n // would return true. Use the own-property check to avoid\n // accidentally accepting inherited `Object.prototype` keys.\n return Object.prototype.hasOwnProperty.call(MESSAGES, value);\n}\n\nexport function useT() {\n const { locale } = useRuntime();\n return computed(() => (isSupportedLocale(locale.value) ? MESSAGES[locale.value] : MESSAGES.en));\n}\n","<script setup lang=\"ts\">\n// Spotify plugin View. Shows connection state in the header and the\n// listening data (liked / playlists / recent / now playing) below.\n//\n// State machine:\n// - status === null → loading (initial render)\n// - clientIdConfigured === false → show Configure form\n// - clientIdConfigured === true && connected === false → show Connect button\n// - connected === true → show tabs + the active tab's data\n//\n// Each tab fetches lazily on first activation; refreshes on the\n// \"connected\" pubsub event so a freshly authorised user sees data\n// immediately without manually clicking Refresh.\n\nimport { computed, onMounted, onUnmounted, ref } from \"vue\";\nimport { useRuntime } from \"gui-chat-protocol/vue\";\nimport { useT } from \"./lang\";\nimport type { NormalisedDevice, NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SearchResult } from \"./types\";\n\ninterface StatusData {\n clientIdConfigured: boolean;\n connected: boolean;\n expiresAt: string | null;\n scopes: string[];\n isPremium?: boolean | null;\n displayName?: string;\n}\n\ninterface StatusResponse {\n ok: true;\n data: StatusData;\n}\n\ntype Tab = \"liked\" | \"playlists\" | \"recent\" | \"nowPlaying\" | \"search\";\n\nconst { dispatch, openUrl, pubsub, log } = useRuntime();\nconst t = useT();\n\nconst status = ref<StatusData | null>(null);\nconst activeTab = ref<Tab>(\"liked\");\nconst liked = ref<NormalisedTrack[] | null>(null);\nconst playlists = ref<NormalisedPlaylist[] | null>(null);\nconst recent = ref<RecentlyPlayedItem[] | null>(null);\nconst nowPlaying = ref<NormalisedTrack | null | undefined>(undefined);\nconst searchQuery = ref(\"\");\nconst searchResult = ref<SearchResult | null>(null);\nconst isSearching = ref(false);\nconst tabError = ref<string | null>(null);\nconst isLoadingTab = ref(false);\n\nconst clientIdInput = ref(\"\");\nconst isSavingClientId = ref(false);\nconst saveError = ref<string | null>(null);\n\nconst isConnecting = ref(false);\n\n// Player Controls (PR 3)\nconst devices = ref<NormalisedDevice[]>([]);\nconst playerError = ref<string | null>(null);\nconst isPlayerBusy = ref(false);\nconst volumeInput = ref(50);\n\nasync function refreshStatus(): Promise<void> {\n try {\n const response = await dispatch<StatusResponse>({ kind: \"status\" });\n if (response.ok) status.value = response.data;\n } catch (err) {\n log.warn(\"status fetch failed\", { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function saveClientId(): Promise<void> {\n if (clientIdInput.value.trim().length === 0) return;\n isSavingClientId.value = true;\n saveError.value = null;\n try {\n await dispatch({ kind: \"configure\", clientId: clientIdInput.value.trim() });\n clientIdInput.value = \"\";\n await refreshStatus();\n } catch (err) {\n saveError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isSavingClientId.value = false;\n }\n}\n\n// Spotify's redirect-URI policy:\n// 1. `localhost` is rejected — must use `127.0.0.1` or `[::1]`\n// 2. URI must match the one registered in the Dashboard EXACTLY\n//\n// We honour the actual page origin so that:\n// - Custom port via `--port 3099` → redirectUri uses 3099 (user\n// registers `127.0.0.1:3099/api/...` in their Dashboard).\n// - Vite dev (`5173`) → redirectUri uses 5173 (Vite proxy\n// forwards `/api/*` to the server).\n// - Production (server serves the SPA on 3001) → matches.\n//\n// `localhost` is coerced to `127.0.0.1` because Spotify rejects\n// `localhost` outright — the proxy / browser still hits the same\n// loopback address either way.\nfunction computeRedirectUri(): string {\n const origin = window.location.origin.replace(\"//localhost:\", \"//127.0.0.1:\").replace(\"//localhost/\", \"//127.0.0.1/\");\n return `${origin}/api/plugins/runtime/oauth-callback/spotify`;\n}\n\nasync function startConnect(): Promise<void> {\n isConnecting.value = true;\n try {\n const response = await dispatch<{ ok: boolean; data?: { authorizeUrl?: string }; message?: string }>({\n kind: \"connect\",\n redirectUri: computeRedirectUri(),\n });\n if (response.ok && response.data?.authorizeUrl) {\n // Open the consent screen in a new tab. The original tab\n // keeps the View; the server's `connected` pubsub event\n // (fired after the OAuth callback) refreshes status here so\n // the user sees Premium controls populate without manually\n // reloading. `noopener,noreferrer` for the standard\n // tab-isolation hygiene; the new tab navigates within\n // accounts.spotify.com → 127.0.0.1:3001 which is fine.\n window.open(response.data.authorizeUrl, \"_blank\", \"noopener,noreferrer\");\n } else {\n log.warn(\"connect failed\", { response });\n }\n } catch (err) {\n log.warn(\"connect dispatch threw\", { error: err instanceof Error ? err.message : String(err) });\n } finally {\n isConnecting.value = false;\n }\n}\n\nasync function loadLiked(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedTrack[]; message?: string }>({ kind: \"liked\" });\n if (response.ok && response.data) liked.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadPlaylists(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedPlaylist[]; message?: string }>({ kind: \"playlists\" });\n if (response.ok && response.data) playlists.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadRecent(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: RecentlyPlayedItem[]; message?: string }>({ kind: \"recent\" });\n if (response.ok && response.data) recent.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadNowPlaying(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedTrack | null; message?: string }>({ kind: \"nowPlaying\" });\n if (response.ok) nowPlaying.value = response.data ?? null;\n else tabError.value = response.message ?? t.value.loadFailed;\n // Always also (re)load devices so the dropdown stays current —\n // free users see the list (no controls) and premium users see\n // controls + dropdown together.\n void loadDevices();\n}\n\nasync function loadSearch(): Promise<void> {\n // Search has no auto-fetch on tab activation — there's nothing\n // to search for until the user types a query. But when this is\n // reached via `refreshActiveTab()` (Refresh / Retry buttons), we\n // DO want to re-run the last query if there was one. Otherwise\n // a transient API error on the previous search is unrecoverable\n // (Codex review on PR #1168).\n if (searchQuery.value.trim().length > 0) {\n await runSearch();\n }\n}\n\nasync function runSearch(): Promise<void> {\n const query = searchQuery.value.trim();\n if (query.length === 0) return;\n isSearching.value = true;\n tabError.value = null;\n try {\n const response = await dispatch<{ ok: boolean; data?: SearchResult; message?: string }>({ kind: \"search\", query });\n if (response.ok && response.data) searchResult.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n } catch (err) {\n tabError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isSearching.value = false;\n }\n}\n\nconst TAB_LOADERS: Record<Tab, () => Promise<void>> = {\n liked: loadLiked,\n playlists: loadPlaylists,\n recent: loadRecent,\n nowPlaying: loadNowPlaying,\n search: loadSearch,\n};\n\nfunction tabIsCached(tab: Tab): boolean {\n if (tab === \"liked\") return liked.value !== null;\n if (tab === \"playlists\") return playlists.value !== null;\n if (tab === \"recent\") return recent.value !== null;\n if (tab === \"nowPlaying\") return nowPlaying.value !== undefined;\n // Search tab is always \"cached\" — its content is driven by the\n // input box, not by tab activation.\n return true;\n}\n\nasync function loadActiveTab(force = false): Promise<void> {\n if (!status.value?.connected) return;\n // Always clear an inherited error from a previous tab BEFORE the\n // cache-hit early-return — otherwise switching from a tab whose\n // last load failed onto a cached/never-loads tab (notably Search,\n // whose tabIsCached is always true) leaves the prior error message\n // floating over unrelated content (Codex review on PR #1168).\n tabError.value = null;\n // Cache hit on tab switch — header comment promises lazy loading,\n // so a click on a tab whose data is already loaded must NOT\n // re-dispatch (CodeRabbit + Sourcery review on PR #1166).\n // Refresh button passes force=true to bypass.\n if (!force && tabIsCached(activeTab.value)) return;\n isLoadingTab.value = true;\n try {\n await TAB_LOADERS[activeTab.value]();\n } catch (err) {\n tabError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isLoadingTab.value = false;\n }\n}\n\nfunction selectTab(next: Tab): void {\n activeTab.value = next;\n void loadActiveTab();\n}\n\nfunction refreshActiveTab(): void {\n void loadActiveTab(true);\n}\n\n// Player Controls (PR 3) — Premium-gated; getDevices works for Free\n// users too so the dropdown loads regardless of plan.\nasync function loadDevices(): Promise<void> {\n try {\n const response = await dispatch<{ ok: boolean; data?: NormalisedDevice[]; message?: string }>({ kind: \"getDevices\" });\n if (response.ok && response.data) devices.value = response.data;\n } catch (err) {\n log.warn(\"getDevices failed\", { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function dispatchPlayer(args: object, busyMessage: string): Promise<void> {\n isPlayerBusy.value = true;\n playerError.value = null;\n try {\n const response = await dispatch<{ ok: boolean; message?: string }>(args);\n if (!response.ok) {\n playerError.value = response.message ?? busyMessage;\n } else {\n // Refresh now-playing card after a successful action so the\n // user sees the new track / pause state.\n await loadNowPlaying();\n }\n } catch (err) {\n playerError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isPlayerBusy.value = false;\n }\n}\n\nfunction playerPlay(): void {\n void dispatchPlayer({ kind: \"play\" }, t.value.loadFailed);\n}\nfunction playerPause(): void {\n void dispatchPlayer({ kind: \"pause\" }, t.value.loadFailed);\n}\nfunction playerNext(): void {\n void dispatchPlayer({ kind: \"next\" }, t.value.loadFailed);\n}\nfunction playerPrevious(): void {\n void dispatchPlayer({ kind: \"previous\" }, t.value.loadFailed);\n}\nfunction playerVolume(): void {\n void dispatchPlayer({ kind: \"setVolume\", volumePercent: volumeInput.value }, t.value.loadFailed);\n}\nfunction playerTransfer(deviceId: string): void {\n void dispatchPlayer({ kind: \"transferPlayback\", deviceId, play: false }, t.value.loadFailed).then(() => loadDevices());\n}\n\n// `NormalisedTrack.url` is optional (locally-uploaded tracks and\n// podcast episodes carry no `external_urls.spotify`). Guard the\n// click so we don't `openUrl(undefined)` and end up navigating to\n// \"undefined\" or to a sentinel empty string.\nfunction safeOpenUrl(url: string | undefined): void {\n if (typeof url === \"string\" && url.length > 0) openUrl(url);\n}\n\nfunction formatDuration(ms: number): string {\n const totalSeconds = Math.floor(ms / 1000);\n const mins = Math.floor(totalSeconds / 60);\n const secs = totalSeconds % 60;\n return `${mins}:${secs.toString().padStart(2, \"0\")}`;\n}\n\n// Spotify can return all four categories as empty arrays for a query\n// that has no hits in any of them. Without an explicit empty-state\n// the View renders a blank box (Codex review on PR #1168).\nconst searchResultIsEmpty = computed(() => {\n const result = searchResult.value;\n if (!result) return false;\n return !(result.tracks?.length || result.artists?.length || result.albums?.length || result.playlists?.length);\n});\n\nconst expiryDisplay = computed(() => {\n if (!status.value?.expiresAt) return \"\";\n try {\n return new Date(status.value.expiresAt).toLocaleString();\n } catch {\n return status.value.expiresAt;\n }\n});\n\nconst unsubs: Array<() => void> = [];\nonMounted(() => {\n unsubs.push(\n pubsub.subscribe(\"connected\", () => {\n // OAuth completion → refresh status, then refetch the active\n // tab (force=true bypasses the cache so we don't show stale\n // data after a reconnect with new scopes).\n void refreshStatus().then(() => loadActiveTab(true));\n }),\n );\n void refreshStatus().then(() => loadActiveTab());\n});\nonUnmounted(() => {\n for (const unsub of unsubs) unsub();\n});\n</script>\n\n<template>\n <div class=\"spotify-view\">\n <header class=\"spotify-header\">\n <h2>{{ t.title }}</h2>\n <div class=\"spotify-status\">\n <template v-if=\"status === null\">{{ t.loading }}</template>\n <template v-else-if=\"!status.clientIdConfigured\">{{ t.notConfigured }}</template>\n <template v-else-if=\"!status.connected\">{{ t.notConnected }}</template>\n <template v-else>\n <span class=\"spotify-connected-pill\">{{ t.connected }}</span>\n <span class=\"spotify-expiry\">{{ t.expiresAt }}: {{ expiryDisplay }}</span>\n <!-- PR 3 added two OAuth scopes; existing PR 1/2 connections\n work for read-only kinds but Player Controls hit\n `403 Insufficient client scope` until reconnect. The\n Reconnect button is always available so the user can\n re-authorise without manually deleting tokens.json. -->\n <button type=\"button\" class=\"spotify-reconnect\" :disabled=\"isConnecting\" @click=\"startConnect\">\n {{ isConnecting ? t.connecting : t.reconnect }}\n </button>\n </template>\n </div>\n </header>\n\n <!-- Configure form (no Client ID yet) -->\n <section v-if=\"status && !status.clientIdConfigured\" class=\"spotify-configure\">\n <p class=\"spotify-configure-help\">{{ t.configureHelp }}</p>\n <form class=\"spotify-configure-form\" @submit.prevent=\"saveClientId\">\n <input v-model=\"clientIdInput\" :placeholder=\"t.configurePlaceholder\" class=\"spotify-input\" type=\"text\" autocomplete=\"off\" />\n <button type=\"submit\" :disabled=\"isSavingClientId || clientIdInput.trim().length === 0\" class=\"spotify-btn-primary\">\n {{ isSavingClientId ? t.saving : t.save }}\n </button>\n </form>\n <p v-if=\"saveError\" class=\"spotify-error\">{{ saveError }}</p>\n </section>\n\n <!-- Connect button (Client ID set, no tokens) -->\n <section v-else-if=\"status && status.clientIdConfigured && !status.connected\" class=\"spotify-connect-section\">\n <button type=\"button\" :disabled=\"isConnecting\" class=\"spotify-btn-primary\" @click=\"startConnect\">\n {{ isConnecting ? t.connecting : t.connect }}\n </button>\n </section>\n\n <!-- Connected: tabs + content -->\n <div v-else-if=\"status?.connected\" class=\"spotify-connected\">\n <!-- ARIA: `role=\"tablist\"` may only contain `role=\"tab\"` elements,\n so the Refresh control sits outside the nav (CodeRabbit\n review on PR #1166). -->\n <div class=\"spotify-tab-row\">\n <nav class=\"spotify-tabs\" role=\"tablist\">\n <button\n v-for=\"tab in ['liked', 'playlists', 'recent', 'nowPlaying', 'search'] as const\"\n :key=\"tab\"\n type=\"button\"\n role=\"tab\"\n :aria-selected=\"activeTab === tab\"\n :class=\"['spotify-tab', { 'spotify-tab-active': activeTab === tab }]\"\n @click=\"selectTab(tab)\"\n >\n {{\n tab === \"liked\"\n ? t.tabLiked\n : tab === \"playlists\"\n ? t.tabPlaylists\n : tab === \"recent\"\n ? t.tabRecent\n : tab === \"nowPlaying\"\n ? t.tabNowPlaying\n : t.tabSearch\n }}\n </button>\n </nav>\n <button v-if=\"activeTab !== 'search'\" type=\"button\" class=\"spotify-refresh\" @click=\"refreshActiveTab\">{{ t.refresh }}</button>\n </div>\n\n <div class=\"spotify-content\">\n <p v-if=\"isLoadingTab\" class=\"spotify-loading\">{{ t.loading }}</p>\n <p v-else-if=\"tabError\" class=\"spotify-error\">\n {{ tabError }} <button class=\"spotify-retry\" @click=\"refreshActiveTab\">{{ t.retry }}</button>\n </p>\n\n <ul v-else-if=\"activeTab === 'liked' && liked && liked.length > 0\" class=\"spotify-list\">\n <li v-for=\"track in liked\" :key=\"track.id\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(track.url)\">\n <img v-if=\"track.imageUrl\" :src=\"track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ track.artists.join(\", \") }}</span>\n </span>\n <span class=\"spotify-track-duration\">{{ formatDuration(track.durationMs) }}</span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'liked'\" class=\"spotify-empty\">{{ t.emptyLiked }}</p>\n\n <ul v-else-if=\"activeTab === 'playlists' && playlists && playlists.length > 0\" class=\"spotify-list\">\n <li v-for=\"playlist in playlists\" :key=\"playlist.id\" class=\"spotify-playlist-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(playlist.url)\">\n <img v-if=\"playlist.imageUrl\" :src=\"playlist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ playlist.name }}</span>\n <span class=\"spotify-track-artists\">{{ playlist.trackCount }} {{ t.tracksCount }}</span>\n </span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'playlists'\" class=\"spotify-empty\">{{ t.emptyPlaylists }}</p>\n\n <ul v-else-if=\"activeTab === 'recent' && recent && recent.length > 0\" class=\"spotify-list\">\n <li v-for=\"item in recent\" :key=\"`${item.track.id}-${item.playedAt}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(item.track.url)\">\n <img v-if=\"item.track.imageUrl\" :src=\"item.track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ item.track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ item.track.artists.join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'recent'\" class=\"spotify-empty\">{{ t.emptyRecent }}</p>\n\n <template v-else-if=\"activeTab === 'nowPlaying'\">\n <div v-if=\"nowPlaying\" class=\"spotify-now-playing\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(nowPlaying.url)\">\n <img v-if=\"nowPlaying.imageUrl\" :src=\"nowPlaying.imageUrl\" alt=\"\" class=\"spotify-now-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ nowPlaying.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ nowPlaying.artists.join(\", \") }}</span>\n <span class=\"spotify-track-album\">{{ nowPlaying.album }}</span>\n </span>\n </button>\n </div>\n <p v-else class=\"spotify-empty\">{{ t.emptyNowPlaying }}</p>\n\n <!-- Player Controls (PR 3). Premium-gated: Free users see\n a notice instead of buttons. The device dropdown is\n always visible (helps the user transfer playback to\n a different device + diagnose \"no active device\"). -->\n <section v-if=\"status?.isPremium === false\" class=\"spotify-player-locked\">\n <h3>{{ t.playerControls }}</h3>\n <p>{{ t.premiumRequired }}</p>\n </section>\n <section v-else-if=\"status?.isPremium === true\" class=\"spotify-player\">\n <h3>{{ t.playerControls }}</h3>\n <div class=\"spotify-player-buttons\">\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPrevious\" :disabled=\"isPlayerBusy\" @click=\"playerPrevious\">⏮</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPause\" :disabled=\"isPlayerBusy\" @click=\"playerPause\">⏸</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPlay\" :disabled=\"isPlayerBusy\" @click=\"playerPlay\">▶</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnNext\" :disabled=\"isPlayerBusy\" @click=\"playerNext\">⏭</button>\n </div>\n <div class=\"spotify-player-volume\">\n <label for=\"spotify-volume\">{{ t.volume }}: {{ volumeInput }}</label>\n <input id=\"spotify-volume\" v-model.number=\"volumeInput\" type=\"range\" min=\"0\" max=\"100\" :disabled=\"isPlayerBusy\" @change=\"playerVolume\" />\n </div>\n <p v-if=\"playerError\" class=\"spotify-error\">{{ playerError }}</p>\n </section>\n\n <section v-if=\"devices.length > 0\" class=\"spotify-devices\">\n <h3>{{ t.devices }}</h3>\n <ul class=\"spotify-list\">\n <!-- Some Spotify devices ship with `id: null` (restricted\n for DRM / account-state reasons). They're displayed\n informationally but the Transfer button is disabled —\n `playerTransfer` requires a usable ID. Fallback key\n is `name` so Vue's :key stays unique-ish. -->\n <li v-for=\"(device, idx) in devices\" :key=\"device.id ?? `name:${device.name}:${idx}`\" class=\"spotify-device-row\">\n <span class=\"spotify-device-name\">{{ device.name }}</span>\n <span class=\"spotify-device-type\">{{ device.type }}</span>\n <span v-if=\"device.isActive\" class=\"spotify-device-active\">{{ t.deviceActive }}</span>\n <button\n v-else-if=\"status?.isPremium === true\"\n type=\"button\"\n class=\"spotify-device-transfer\"\n :disabled=\"isPlayerBusy || device.id === null\"\n :aria-disabled=\"device.id === null\"\n @click=\"device.id !== null && playerTransfer(device.id)\"\n >\n {{ t.transferToDevice }}\n </button>\n </li>\n </ul>\n </section>\n </template>\n\n <!-- Search panel: input + grouped results. Unlike the other\n tabs, no auto-fetch on tab activation — driven by the\n user's submit. -->\n <template v-else-if=\"activeTab === 'search'\">\n <form class=\"spotify-search-form\" @submit.prevent=\"runSearch\">\n <!-- A placeholder is not an accessible name (it disappears on\n input). Pair the input with an explicit aria-label so\n screen readers always announce the control regardless\n of whether the user has typed anything yet (Codex\n review on PR #1168). -->\n <input\n v-model=\"searchQuery\"\n :placeholder=\"t.searchPlaceholder\"\n :aria-label=\"t.searchPlaceholder\"\n class=\"spotify-input\"\n type=\"search\"\n autocomplete=\"off\"\n :disabled=\"isSearching\"\n />\n <button type=\"submit\" class=\"spotify-btn-primary\" :disabled=\"isSearching || searchQuery.trim().length === 0\">\n {{ isSearching ? t.loading : t.searchSubmit }}\n </button>\n </form>\n\n <div v-if=\"searchResult && searchResultIsEmpty\" class=\"spotify-empty\">{{ t.searchEmpty }}</div>\n <div v-else-if=\"searchResult\" class=\"spotify-search-results\">\n <section v-if=\"searchResult.tracks && searchResult.tracks.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchTracks }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"track in searchResult.tracks\" :key=\"`t-${track.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(track.url)\">\n <img v-if=\"track.imageUrl\" :src=\"track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ track.artists.join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.artists && searchResult.artists.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchArtists }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"artist in searchResult.artists\" :key=\"`a-${artist.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(artist.url)\">\n <img v-if=\"artist.imageUrl\" :src=\"artist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ artist.name }}</span>\n <span v-if=\"artist.genres.length > 0\" class=\"spotify-track-artists\">{{ artist.genres.slice(0, 3).join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.albums && searchResult.albums.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchAlbums }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"album in searchResult.albums\" :key=\"`al-${album.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(album.url)\">\n <img v-if=\"album.imageUrl\" :src=\"album.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ album.name }}</span>\n <span class=\"spotify-track-artists\">\n {{ album.artists.join(\", \") }}<template v-if=\"album.releaseDate\"> · {{ album.releaseDate.slice(0, 4) }}</template>\n </span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.playlists && searchResult.playlists.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchPlaylists }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"playlist in searchResult.playlists\" :key=\"`p-${playlist.id}`\" class=\"spotify-playlist-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(playlist.url)\">\n <img v-if=\"playlist.imageUrl\" :src=\"playlist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ playlist.name }}</span>\n <span class=\"spotify-track-artists\">{{ playlist.trackCount }} {{ t.tracksCount }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n </div>\n <p v-else class=\"spotify-empty\">{{ t.searchHint }}</p>\n </template>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.spotify-view {\n /* `h-full + flex column` so the content area can take the\n * remaining vertical space and scroll, instead of overflowing\n * into the host's chrome below. Same shape as todo-plugin's\n * View. */\n height: 100%;\n display: flex;\n flex-direction: column;\n padding: 1rem;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n /* Critical: a flex child whose intrinsic content is taller than\n * its allotted space won't shrink unless `min-height: 0`. Without\n * this the scrollable content area's `overflow: auto` is ignored\n * and the parent grows past the canvas. */\n min-height: 0;\n}\n.spotify-connected {\n flex: 1;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n.spotify-content {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n.spotify-header h2 {\n font-size: 1.25rem;\n font-weight: 600;\n margin: 0 0 0.5rem;\n}\n.spotify-status {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.875rem;\n color: #6b7280;\n margin-bottom: 1rem;\n}\n.spotify-connected-pill {\n background: #1ed760;\n color: white;\n padding: 0.125rem 0.5rem;\n border-radius: 9999px;\n font-size: 0.75rem;\n font-weight: 500;\n}\n.spotify-reconnect {\n margin-left: auto;\n background: none;\n border: 1px solid #d1d5db;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n cursor: pointer;\n font-size: 0.75rem;\n color: #374151;\n}\n.spotify-reconnect:hover:not(:disabled) {\n background: #f3f4f6;\n}\n.spotify-reconnect:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n.spotify-expiry {\n font-size: 0.75rem;\n color: #9ca3af;\n}\n.spotify-configure {\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n padding: 1rem;\n margin-bottom: 1rem;\n background: #fafafa;\n}\n.spotify-configure-help {\n margin: 0 0 0.75rem;\n font-size: 0.875rem;\n}\n.spotify-configure-form {\n display: flex;\n gap: 0.5rem;\n}\n.spotify-input {\n flex: 1;\n padding: 0.5rem;\n border: 1px solid #d1d5db;\n border-radius: 0.375rem;\n font-family: inherit;\n}\n.spotify-btn-primary {\n background: #1ed760;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 0.375rem;\n font-weight: 500;\n cursor: pointer;\n}\n.spotify-btn-primary:disabled {\n background: #d1d5db;\n cursor: not-allowed;\n}\n.spotify-connect-section {\n display: flex;\n justify-content: center;\n padding: 2rem;\n}\n.spotify-tab-row {\n display: flex;\n align-items: stretch;\n border-bottom: 1px solid #e5e7eb;\n margin-bottom: 1rem;\n}\n.spotify-tabs {\n display: flex;\n gap: 0.25rem;\n flex: 1;\n}\n.spotify-tab {\n background: none;\n border: none;\n padding: 0.5rem 1rem;\n cursor: pointer;\n font-family: inherit;\n font-size: 0.875rem;\n color: #6b7280;\n border-bottom: 2px solid transparent;\n}\n.spotify-tab-active {\n color: #1ed760;\n border-bottom-color: #1ed760;\n font-weight: 500;\n}\n.spotify-refresh {\n background: none;\n border: none;\n color: #6b7280;\n cursor: pointer;\n font-size: 0.75rem;\n padding: 0 0.5rem;\n}\n.spotify-list {\n list-style: none;\n padding: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n}\n.spotify-track-row,\n.spotify-playlist-row {\n border-radius: 0.375rem;\n}\n.spotify-track-row:hover,\n.spotify-playlist-row:hover {\n background: #f5f5f5;\n}\n.spotify-track-link {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.5rem;\n background: none;\n border: none;\n cursor: pointer;\n text-align: left;\n font-family: inherit;\n}\n.spotify-cover {\n width: 2.5rem;\n height: 2.5rem;\n border-radius: 0.25rem;\n object-fit: cover;\n flex-shrink: 0;\n}\n.spotify-track-meta {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n min-width: 0;\n}\n.spotify-track-name {\n font-weight: 500;\n font-size: 0.875rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.spotify-track-artists {\n font-size: 0.75rem;\n color: #6b7280;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.spotify-track-album {\n font-size: 0.75rem;\n color: #9ca3af;\n}\n.spotify-track-duration {\n font-size: 0.75rem;\n color: #9ca3af;\n font-variant-numeric: tabular-nums;\n}\n.spotify-now-playing {\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n padding: 1rem;\n}\n.spotify-search-form {\n display: flex;\n gap: 0.5rem;\n margin-bottom: 1rem;\n}\n.spotify-search-results {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n.spotify-search-section h3 {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin: 0 0 0.25rem;\n padding: 0;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n}\n.spotify-player,\n.spotify-player-locked,\n.spotify-devices {\n margin-top: 1rem;\n padding: 0.75rem 1rem;\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n}\n.spotify-player-locked {\n background: #fafafa;\n color: #6b7280;\n}\n.spotify-player h3,\n.spotify-player-locked h3,\n.spotify-devices h3 {\n font-size: 0.875rem;\n font-weight: 600;\n margin: 0 0 0.5rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: #6b7280;\n}\n.spotify-player-buttons {\n display: flex;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n}\n.spotify-player-btn {\n flex: 1;\n padding: 0.5rem;\n background: #1ed760;\n color: white;\n border: none;\n border-radius: 0.375rem;\n font-size: 1rem;\n cursor: pointer;\n}\n.spotify-player-btn:disabled {\n background: #d1d5db;\n cursor: not-allowed;\n}\n.spotify-player-volume {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.875rem;\n}\n.spotify-player-volume input[type=\"range\"] {\n flex: 1;\n}\n.spotify-device-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.375rem 0.5rem;\n font-size: 0.875rem;\n}\n.spotify-device-name {\n flex: 1;\n font-weight: 500;\n}\n.spotify-device-type {\n color: #6b7280;\n font-size: 0.75rem;\n}\n.spotify-device-active {\n background: #1ed760;\n color: white;\n padding: 0 0.5rem;\n border-radius: 9999px;\n font-size: 0.75rem;\n}\n.spotify-device-transfer {\n background: none;\n border: 1px solid #d1d5db;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n cursor: pointer;\n font-size: 0.75rem;\n}\n.spotify-now-cover {\n width: 4rem;\n height: 4rem;\n border-radius: 0.375rem;\n}\n.spotify-empty,\n.spotify-loading {\n color: #6b7280;\n font-size: 0.875rem;\n text-align: center;\n padding: 2rem;\n}\n.spotify-error {\n color: #dc2626;\n font-size: 0.875rem;\n padding: 0.5rem;\n background: #fef2f2;\n border-radius: 0.375rem;\n}\n.spotify-retry {\n background: none;\n border: 1px solid currentColor;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n margin-left: 0.5rem;\n cursor: pointer;\n color: inherit;\n font-size: inherit;\n}\n</style>\n","<script setup lang=\"ts\">\n// Spotify plugin View. Shows connection state in the header and the\n// listening data (liked / playlists / recent / now playing) below.\n//\n// State machine:\n// - status === null → loading (initial render)\n// - clientIdConfigured === false → show Configure form\n// - clientIdConfigured === true && connected === false → show Connect button\n// - connected === true → show tabs + the active tab's data\n//\n// Each tab fetches lazily on first activation; refreshes on the\n// \"connected\" pubsub event so a freshly authorised user sees data\n// immediately without manually clicking Refresh.\n\nimport { computed, onMounted, onUnmounted, ref } from \"vue\";\nimport { useRuntime } from \"gui-chat-protocol/vue\";\nimport { useT } from \"./lang\";\nimport type { NormalisedDevice, NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SearchResult } from \"./types\";\n\ninterface StatusData {\n clientIdConfigured: boolean;\n connected: boolean;\n expiresAt: string | null;\n scopes: string[];\n isPremium?: boolean | null;\n displayName?: string;\n}\n\ninterface StatusResponse {\n ok: true;\n data: StatusData;\n}\n\ntype Tab = \"liked\" | \"playlists\" | \"recent\" | \"nowPlaying\" | \"search\";\n\nconst { dispatch, openUrl, pubsub, log } = useRuntime();\nconst t = useT();\n\nconst status = ref<StatusData | null>(null);\nconst activeTab = ref<Tab>(\"liked\");\nconst liked = ref<NormalisedTrack[] | null>(null);\nconst playlists = ref<NormalisedPlaylist[] | null>(null);\nconst recent = ref<RecentlyPlayedItem[] | null>(null);\nconst nowPlaying = ref<NormalisedTrack | null | undefined>(undefined);\nconst searchQuery = ref(\"\");\nconst searchResult = ref<SearchResult | null>(null);\nconst isSearching = ref(false);\nconst tabError = ref<string | null>(null);\nconst isLoadingTab = ref(false);\n\nconst clientIdInput = ref(\"\");\nconst isSavingClientId = ref(false);\nconst saveError = ref<string | null>(null);\n\nconst isConnecting = ref(false);\n\n// Player Controls (PR 3)\nconst devices = ref<NormalisedDevice[]>([]);\nconst playerError = ref<string | null>(null);\nconst isPlayerBusy = ref(false);\nconst volumeInput = ref(50);\n\nasync function refreshStatus(): Promise<void> {\n try {\n const response = await dispatch<StatusResponse>({ kind: \"status\" });\n if (response.ok) status.value = response.data;\n } catch (err) {\n log.warn(\"status fetch failed\", { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function saveClientId(): Promise<void> {\n if (clientIdInput.value.trim().length === 0) return;\n isSavingClientId.value = true;\n saveError.value = null;\n try {\n await dispatch({ kind: \"configure\", clientId: clientIdInput.value.trim() });\n clientIdInput.value = \"\";\n await refreshStatus();\n } catch (err) {\n saveError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isSavingClientId.value = false;\n }\n}\n\n// Spotify's redirect-URI policy:\n// 1. `localhost` is rejected — must use `127.0.0.1` or `[::1]`\n// 2. URI must match the one registered in the Dashboard EXACTLY\n//\n// We honour the actual page origin so that:\n// - Custom port via `--port 3099` → redirectUri uses 3099 (user\n// registers `127.0.0.1:3099/api/...` in their Dashboard).\n// - Vite dev (`5173`) → redirectUri uses 5173 (Vite proxy\n// forwards `/api/*` to the server).\n// - Production (server serves the SPA on 3001) → matches.\n//\n// `localhost` is coerced to `127.0.0.1` because Spotify rejects\n// `localhost` outright — the proxy / browser still hits the same\n// loopback address either way.\nfunction computeRedirectUri(): string {\n const origin = window.location.origin.replace(\"//localhost:\", \"//127.0.0.1:\").replace(\"//localhost/\", \"//127.0.0.1/\");\n return `${origin}/api/plugins/runtime/oauth-callback/spotify`;\n}\n\nasync function startConnect(): Promise<void> {\n isConnecting.value = true;\n try {\n const response = await dispatch<{ ok: boolean; data?: { authorizeUrl?: string }; message?: string }>({\n kind: \"connect\",\n redirectUri: computeRedirectUri(),\n });\n if (response.ok && response.data?.authorizeUrl) {\n // Open the consent screen in a new tab. The original tab\n // keeps the View; the server's `connected` pubsub event\n // (fired after the OAuth callback) refreshes status here so\n // the user sees Premium controls populate without manually\n // reloading. `noopener,noreferrer` for the standard\n // tab-isolation hygiene; the new tab navigates within\n // accounts.spotify.com → 127.0.0.1:3001 which is fine.\n window.open(response.data.authorizeUrl, \"_blank\", \"noopener,noreferrer\");\n } else {\n log.warn(\"connect failed\", { response });\n }\n } catch (err) {\n log.warn(\"connect dispatch threw\", { error: err instanceof Error ? err.message : String(err) });\n } finally {\n isConnecting.value = false;\n }\n}\n\nasync function loadLiked(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedTrack[]; message?: string }>({ kind: \"liked\" });\n if (response.ok && response.data) liked.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadPlaylists(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedPlaylist[]; message?: string }>({ kind: \"playlists\" });\n if (response.ok && response.data) playlists.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadRecent(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: RecentlyPlayedItem[]; message?: string }>({ kind: \"recent\" });\n if (response.ok && response.data) recent.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n}\n\nasync function loadNowPlaying(): Promise<void> {\n const response = await dispatch<{ ok: boolean; data?: NormalisedTrack | null; message?: string }>({ kind: \"nowPlaying\" });\n if (response.ok) nowPlaying.value = response.data ?? null;\n else tabError.value = response.message ?? t.value.loadFailed;\n // Always also (re)load devices so the dropdown stays current —\n // free users see the list (no controls) and premium users see\n // controls + dropdown together.\n void loadDevices();\n}\n\nasync function loadSearch(): Promise<void> {\n // Search has no auto-fetch on tab activation — there's nothing\n // to search for until the user types a query. But when this is\n // reached via `refreshActiveTab()` (Refresh / Retry buttons), we\n // DO want to re-run the last query if there was one. Otherwise\n // a transient API error on the previous search is unrecoverable\n // (Codex review on PR #1168).\n if (searchQuery.value.trim().length > 0) {\n await runSearch();\n }\n}\n\nasync function runSearch(): Promise<void> {\n const query = searchQuery.value.trim();\n if (query.length === 0) return;\n isSearching.value = true;\n tabError.value = null;\n try {\n const response = await dispatch<{ ok: boolean; data?: SearchResult; message?: string }>({ kind: \"search\", query });\n if (response.ok && response.data) searchResult.value = response.data;\n else tabError.value = response.message ?? t.value.loadFailed;\n } catch (err) {\n tabError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isSearching.value = false;\n }\n}\n\nconst TAB_LOADERS: Record<Tab, () => Promise<void>> = {\n liked: loadLiked,\n playlists: loadPlaylists,\n recent: loadRecent,\n nowPlaying: loadNowPlaying,\n search: loadSearch,\n};\n\nfunction tabIsCached(tab: Tab): boolean {\n if (tab === \"liked\") return liked.value !== null;\n if (tab === \"playlists\") return playlists.value !== null;\n if (tab === \"recent\") return recent.value !== null;\n if (tab === \"nowPlaying\") return nowPlaying.value !== undefined;\n // Search tab is always \"cached\" — its content is driven by the\n // input box, not by tab activation.\n return true;\n}\n\nasync function loadActiveTab(force = false): Promise<void> {\n if (!status.value?.connected) return;\n // Always clear an inherited error from a previous tab BEFORE the\n // cache-hit early-return — otherwise switching from a tab whose\n // last load failed onto a cached/never-loads tab (notably Search,\n // whose tabIsCached is always true) leaves the prior error message\n // floating over unrelated content (Codex review on PR #1168).\n tabError.value = null;\n // Cache hit on tab switch — header comment promises lazy loading,\n // so a click on a tab whose data is already loaded must NOT\n // re-dispatch (CodeRabbit + Sourcery review on PR #1166).\n // Refresh button passes force=true to bypass.\n if (!force && tabIsCached(activeTab.value)) return;\n isLoadingTab.value = true;\n try {\n await TAB_LOADERS[activeTab.value]();\n } catch (err) {\n tabError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isLoadingTab.value = false;\n }\n}\n\nfunction selectTab(next: Tab): void {\n activeTab.value = next;\n void loadActiveTab();\n}\n\nfunction refreshActiveTab(): void {\n void loadActiveTab(true);\n}\n\n// Player Controls (PR 3) — Premium-gated; getDevices works for Free\n// users too so the dropdown loads regardless of plan.\nasync function loadDevices(): Promise<void> {\n try {\n const response = await dispatch<{ ok: boolean; data?: NormalisedDevice[]; message?: string }>({ kind: \"getDevices\" });\n if (response.ok && response.data) devices.value = response.data;\n } catch (err) {\n log.warn(\"getDevices failed\", { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function dispatchPlayer(args: object, busyMessage: string): Promise<void> {\n isPlayerBusy.value = true;\n playerError.value = null;\n try {\n const response = await dispatch<{ ok: boolean; message?: string }>(args);\n if (!response.ok) {\n playerError.value = response.message ?? busyMessage;\n } else {\n // Refresh now-playing card after a successful action so the\n // user sees the new track / pause state.\n await loadNowPlaying();\n }\n } catch (err) {\n playerError.value = err instanceof Error ? err.message : String(err);\n } finally {\n isPlayerBusy.value = false;\n }\n}\n\nfunction playerPlay(): void {\n void dispatchPlayer({ kind: \"play\" }, t.value.loadFailed);\n}\nfunction playerPause(): void {\n void dispatchPlayer({ kind: \"pause\" }, t.value.loadFailed);\n}\nfunction playerNext(): void {\n void dispatchPlayer({ kind: \"next\" }, t.value.loadFailed);\n}\nfunction playerPrevious(): void {\n void dispatchPlayer({ kind: \"previous\" }, t.value.loadFailed);\n}\nfunction playerVolume(): void {\n void dispatchPlayer({ kind: \"setVolume\", volumePercent: volumeInput.value }, t.value.loadFailed);\n}\nfunction playerTransfer(deviceId: string): void {\n void dispatchPlayer({ kind: \"transferPlayback\", deviceId, play: false }, t.value.loadFailed).then(() => loadDevices());\n}\n\n// `NormalisedTrack.url` is optional (locally-uploaded tracks and\n// podcast episodes carry no `external_urls.spotify`). Guard the\n// click so we don't `openUrl(undefined)` and end up navigating to\n// \"undefined\" or to a sentinel empty string.\nfunction safeOpenUrl(url: string | undefined): void {\n if (typeof url === \"string\" && url.length > 0) openUrl(url);\n}\n\nfunction formatDuration(ms: number): string {\n const totalSeconds = Math.floor(ms / 1000);\n const mins = Math.floor(totalSeconds / 60);\n const secs = totalSeconds % 60;\n return `${mins}:${secs.toString().padStart(2, \"0\")}`;\n}\n\n// Spotify can return all four categories as empty arrays for a query\n// that has no hits in any of them. Without an explicit empty-state\n// the View renders a blank box (Codex review on PR #1168).\nconst searchResultIsEmpty = computed(() => {\n const result = searchResult.value;\n if (!result) return false;\n return !(result.tracks?.length || result.artists?.length || result.albums?.length || result.playlists?.length);\n});\n\nconst expiryDisplay = computed(() => {\n if (!status.value?.expiresAt) return \"\";\n try {\n return new Date(status.value.expiresAt).toLocaleString();\n } catch {\n return status.value.expiresAt;\n }\n});\n\nconst unsubs: Array<() => void> = [];\nonMounted(() => {\n unsubs.push(\n pubsub.subscribe(\"connected\", () => {\n // OAuth completion → refresh status, then refetch the active\n // tab (force=true bypasses the cache so we don't show stale\n // data after a reconnect with new scopes).\n void refreshStatus().then(() => loadActiveTab(true));\n }),\n );\n void refreshStatus().then(() => loadActiveTab());\n});\nonUnmounted(() => {\n for (const unsub of unsubs) unsub();\n});\n</script>\n\n<template>\n <div class=\"spotify-view\">\n <header class=\"spotify-header\">\n <h2>{{ t.title }}</h2>\n <div class=\"spotify-status\">\n <template v-if=\"status === null\">{{ t.loading }}</template>\n <template v-else-if=\"!status.clientIdConfigured\">{{ t.notConfigured }}</template>\n <template v-else-if=\"!status.connected\">{{ t.notConnected }}</template>\n <template v-else>\n <span class=\"spotify-connected-pill\">{{ t.connected }}</span>\n <span class=\"spotify-expiry\">{{ t.expiresAt }}: {{ expiryDisplay }}</span>\n <!-- PR 3 added two OAuth scopes; existing PR 1/2 connections\n work for read-only kinds but Player Controls hit\n `403 Insufficient client scope` until reconnect. The\n Reconnect button is always available so the user can\n re-authorise without manually deleting tokens.json. -->\n <button type=\"button\" class=\"spotify-reconnect\" :disabled=\"isConnecting\" @click=\"startConnect\">\n {{ isConnecting ? t.connecting : t.reconnect }}\n </button>\n </template>\n </div>\n </header>\n\n <!-- Configure form (no Client ID yet) -->\n <section v-if=\"status && !status.clientIdConfigured\" class=\"spotify-configure\">\n <p class=\"spotify-configure-help\">{{ t.configureHelp }}</p>\n <form class=\"spotify-configure-form\" @submit.prevent=\"saveClientId\">\n <input v-model=\"clientIdInput\" :placeholder=\"t.configurePlaceholder\" class=\"spotify-input\" type=\"text\" autocomplete=\"off\" />\n <button type=\"submit\" :disabled=\"isSavingClientId || clientIdInput.trim().length === 0\" class=\"spotify-btn-primary\">\n {{ isSavingClientId ? t.saving : t.save }}\n </button>\n </form>\n <p v-if=\"saveError\" class=\"spotify-error\">{{ saveError }}</p>\n </section>\n\n <!-- Connect button (Client ID set, no tokens) -->\n <section v-else-if=\"status && status.clientIdConfigured && !status.connected\" class=\"spotify-connect-section\">\n <button type=\"button\" :disabled=\"isConnecting\" class=\"spotify-btn-primary\" @click=\"startConnect\">\n {{ isConnecting ? t.connecting : t.connect }}\n </button>\n </section>\n\n <!-- Connected: tabs + content -->\n <div v-else-if=\"status?.connected\" class=\"spotify-connected\">\n <!-- ARIA: `role=\"tablist\"` may only contain `role=\"tab\"` elements,\n so the Refresh control sits outside the nav (CodeRabbit\n review on PR #1166). -->\n <div class=\"spotify-tab-row\">\n <nav class=\"spotify-tabs\" role=\"tablist\">\n <button\n v-for=\"tab in ['liked', 'playlists', 'recent', 'nowPlaying', 'search'] as const\"\n :key=\"tab\"\n type=\"button\"\n role=\"tab\"\n :aria-selected=\"activeTab === tab\"\n :class=\"['spotify-tab', { 'spotify-tab-active': activeTab === tab }]\"\n @click=\"selectTab(tab)\"\n >\n {{\n tab === \"liked\"\n ? t.tabLiked\n : tab === \"playlists\"\n ? t.tabPlaylists\n : tab === \"recent\"\n ? t.tabRecent\n : tab === \"nowPlaying\"\n ? t.tabNowPlaying\n : t.tabSearch\n }}\n </button>\n </nav>\n <button v-if=\"activeTab !== 'search'\" type=\"button\" class=\"spotify-refresh\" @click=\"refreshActiveTab\">{{ t.refresh }}</button>\n </div>\n\n <div class=\"spotify-content\">\n <p v-if=\"isLoadingTab\" class=\"spotify-loading\">{{ t.loading }}</p>\n <p v-else-if=\"tabError\" class=\"spotify-error\">\n {{ tabError }} <button class=\"spotify-retry\" @click=\"refreshActiveTab\">{{ t.retry }}</button>\n </p>\n\n <ul v-else-if=\"activeTab === 'liked' && liked && liked.length > 0\" class=\"spotify-list\">\n <li v-for=\"track in liked\" :key=\"track.id\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(track.url)\">\n <img v-if=\"track.imageUrl\" :src=\"track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ track.artists.join(\", \") }}</span>\n </span>\n <span class=\"spotify-track-duration\">{{ formatDuration(track.durationMs) }}</span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'liked'\" class=\"spotify-empty\">{{ t.emptyLiked }}</p>\n\n <ul v-else-if=\"activeTab === 'playlists' && playlists && playlists.length > 0\" class=\"spotify-list\">\n <li v-for=\"playlist in playlists\" :key=\"playlist.id\" class=\"spotify-playlist-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(playlist.url)\">\n <img v-if=\"playlist.imageUrl\" :src=\"playlist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ playlist.name }}</span>\n <span class=\"spotify-track-artists\">{{ playlist.trackCount }} {{ t.tracksCount }}</span>\n </span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'playlists'\" class=\"spotify-empty\">{{ t.emptyPlaylists }}</p>\n\n <ul v-else-if=\"activeTab === 'recent' && recent && recent.length > 0\" class=\"spotify-list\">\n <li v-for=\"item in recent\" :key=\"`${item.track.id}-${item.playedAt}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(item.track.url)\">\n <img v-if=\"item.track.imageUrl\" :src=\"item.track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ item.track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ item.track.artists.join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n <p v-else-if=\"activeTab === 'recent'\" class=\"spotify-empty\">{{ t.emptyRecent }}</p>\n\n <template v-else-if=\"activeTab === 'nowPlaying'\">\n <div v-if=\"nowPlaying\" class=\"spotify-now-playing\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(nowPlaying.url)\">\n <img v-if=\"nowPlaying.imageUrl\" :src=\"nowPlaying.imageUrl\" alt=\"\" class=\"spotify-now-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ nowPlaying.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ nowPlaying.artists.join(\", \") }}</span>\n <span class=\"spotify-track-album\">{{ nowPlaying.album }}</span>\n </span>\n </button>\n </div>\n <p v-else class=\"spotify-empty\">{{ t.emptyNowPlaying }}</p>\n\n <!-- Player Controls (PR 3). Premium-gated: Free users see\n a notice instead of buttons. The device dropdown is\n always visible (helps the user transfer playback to\n a different device + diagnose \"no active device\"). -->\n <section v-if=\"status?.isPremium === false\" class=\"spotify-player-locked\">\n <h3>{{ t.playerControls }}</h3>\n <p>{{ t.premiumRequired }}</p>\n </section>\n <section v-else-if=\"status?.isPremium === true\" class=\"spotify-player\">\n <h3>{{ t.playerControls }}</h3>\n <div class=\"spotify-player-buttons\">\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPrevious\" :disabled=\"isPlayerBusy\" @click=\"playerPrevious\">⏮</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPause\" :disabled=\"isPlayerBusy\" @click=\"playerPause\">⏸</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnPlay\" :disabled=\"isPlayerBusy\" @click=\"playerPlay\">▶</button>\n <button type=\"button\" class=\"spotify-player-btn\" :aria-label=\"t.btnNext\" :disabled=\"isPlayerBusy\" @click=\"playerNext\">⏭</button>\n </div>\n <div class=\"spotify-player-volume\">\n <label for=\"spotify-volume\">{{ t.volume }}: {{ volumeInput }}</label>\n <input id=\"spotify-volume\" v-model.number=\"volumeInput\" type=\"range\" min=\"0\" max=\"100\" :disabled=\"isPlayerBusy\" @change=\"playerVolume\" />\n </div>\n <p v-if=\"playerError\" class=\"spotify-error\">{{ playerError }}</p>\n </section>\n\n <section v-if=\"devices.length > 0\" class=\"spotify-devices\">\n <h3>{{ t.devices }}</h3>\n <ul class=\"spotify-list\">\n <!-- Some Spotify devices ship with `id: null` (restricted\n for DRM / account-state reasons). They're displayed\n informationally but the Transfer button is disabled —\n `playerTransfer` requires a usable ID. Fallback key\n is `name` so Vue's :key stays unique-ish. -->\n <li v-for=\"(device, idx) in devices\" :key=\"device.id ?? `name:${device.name}:${idx}`\" class=\"spotify-device-row\">\n <span class=\"spotify-device-name\">{{ device.name }}</span>\n <span class=\"spotify-device-type\">{{ device.type }}</span>\n <span v-if=\"device.isActive\" class=\"spotify-device-active\">{{ t.deviceActive }}</span>\n <button\n v-else-if=\"status?.isPremium === true\"\n type=\"button\"\n class=\"spotify-device-transfer\"\n :disabled=\"isPlayerBusy || device.id === null\"\n :aria-disabled=\"device.id === null\"\n @click=\"device.id !== null && playerTransfer(device.id)\"\n >\n {{ t.transferToDevice }}\n </button>\n </li>\n </ul>\n </section>\n </template>\n\n <!-- Search panel: input + grouped results. Unlike the other\n tabs, no auto-fetch on tab activation — driven by the\n user's submit. -->\n <template v-else-if=\"activeTab === 'search'\">\n <form class=\"spotify-search-form\" @submit.prevent=\"runSearch\">\n <!-- A placeholder is not an accessible name (it disappears on\n input). Pair the input with an explicit aria-label so\n screen readers always announce the control regardless\n of whether the user has typed anything yet (Codex\n review on PR #1168). -->\n <input\n v-model=\"searchQuery\"\n :placeholder=\"t.searchPlaceholder\"\n :aria-label=\"t.searchPlaceholder\"\n class=\"spotify-input\"\n type=\"search\"\n autocomplete=\"off\"\n :disabled=\"isSearching\"\n />\n <button type=\"submit\" class=\"spotify-btn-primary\" :disabled=\"isSearching || searchQuery.trim().length === 0\">\n {{ isSearching ? t.loading : t.searchSubmit }}\n </button>\n </form>\n\n <div v-if=\"searchResult && searchResultIsEmpty\" class=\"spotify-empty\">{{ t.searchEmpty }}</div>\n <div v-else-if=\"searchResult\" class=\"spotify-search-results\">\n <section v-if=\"searchResult.tracks && searchResult.tracks.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchTracks }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"track in searchResult.tracks\" :key=\"`t-${track.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(track.url)\">\n <img v-if=\"track.imageUrl\" :src=\"track.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ track.name }}</span>\n <span class=\"spotify-track-artists\">{{ t.trackBy }} {{ track.artists.join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.artists && searchResult.artists.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchArtists }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"artist in searchResult.artists\" :key=\"`a-${artist.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(artist.url)\">\n <img v-if=\"artist.imageUrl\" :src=\"artist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ artist.name }}</span>\n <span v-if=\"artist.genres.length > 0\" class=\"spotify-track-artists\">{{ artist.genres.slice(0, 3).join(\", \") }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.albums && searchResult.albums.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchAlbums }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"album in searchResult.albums\" :key=\"`al-${album.id}`\" class=\"spotify-track-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(album.url)\">\n <img v-if=\"album.imageUrl\" :src=\"album.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ album.name }}</span>\n <span class=\"spotify-track-artists\">\n {{ album.artists.join(\", \") }}<template v-if=\"album.releaseDate\"> · {{ album.releaseDate.slice(0, 4) }}</template>\n </span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n\n <section v-if=\"searchResult.playlists && searchResult.playlists.length > 0\" class=\"spotify-search-section\">\n <h3>{{ t.searchPlaylists }}</h3>\n <ul class=\"spotify-list\">\n <li v-for=\"playlist in searchResult.playlists\" :key=\"`p-${playlist.id}`\" class=\"spotify-playlist-row\">\n <button type=\"button\" class=\"spotify-track-link\" @click=\"safeOpenUrl(playlist.url)\">\n <img v-if=\"playlist.imageUrl\" :src=\"playlist.imageUrl\" alt=\"\" class=\"spotify-cover\" />\n <span class=\"spotify-track-meta\">\n <span class=\"spotify-track-name\">{{ playlist.name }}</span>\n <span class=\"spotify-track-artists\">{{ playlist.trackCount }} {{ t.tracksCount }}</span>\n </span>\n </button>\n </li>\n </ul>\n </section>\n </div>\n <p v-else class=\"spotify-empty\">{{ t.searchHint }}</p>\n </template>\n </div>\n </div>\n </div>\n</template>\n\n<style scoped>\n.spotify-view {\n /* `h-full + flex column` so the content area can take the\n * remaining vertical space and scroll, instead of overflowing\n * into the host's chrome below. Same shape as todo-plugin's\n * View. */\n height: 100%;\n display: flex;\n flex-direction: column;\n padding: 1rem;\n font-family:\n system-ui,\n -apple-system,\n sans-serif;\n /* Critical: a flex child whose intrinsic content is taller than\n * its allotted space won't shrink unless `min-height: 0`. Without\n * this the scrollable content area's `overflow: auto` is ignored\n * and the parent grows past the canvas. */\n min-height: 0;\n}\n.spotify-connected {\n flex: 1;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n.spotify-content {\n flex: 1;\n overflow-y: auto;\n min-height: 0;\n}\n.spotify-header h2 {\n font-size: 1.25rem;\n font-weight: 600;\n margin: 0 0 0.5rem;\n}\n.spotify-status {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.875rem;\n color: #6b7280;\n margin-bottom: 1rem;\n}\n.spotify-connected-pill {\n background: #1ed760;\n color: white;\n padding: 0.125rem 0.5rem;\n border-radius: 9999px;\n font-size: 0.75rem;\n font-weight: 500;\n}\n.spotify-reconnect {\n margin-left: auto;\n background: none;\n border: 1px solid #d1d5db;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n cursor: pointer;\n font-size: 0.75rem;\n color: #374151;\n}\n.spotify-reconnect:hover:not(:disabled) {\n background: #f3f4f6;\n}\n.spotify-reconnect:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n.spotify-expiry {\n font-size: 0.75rem;\n color: #9ca3af;\n}\n.spotify-configure {\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n padding: 1rem;\n margin-bottom: 1rem;\n background: #fafafa;\n}\n.spotify-configure-help {\n margin: 0 0 0.75rem;\n font-size: 0.875rem;\n}\n.spotify-configure-form {\n display: flex;\n gap: 0.5rem;\n}\n.spotify-input {\n flex: 1;\n padding: 0.5rem;\n border: 1px solid #d1d5db;\n border-radius: 0.375rem;\n font-family: inherit;\n}\n.spotify-btn-primary {\n background: #1ed760;\n color: white;\n border: none;\n padding: 0.5rem 1rem;\n border-radius: 0.375rem;\n font-weight: 500;\n cursor: pointer;\n}\n.spotify-btn-primary:disabled {\n background: #d1d5db;\n cursor: not-allowed;\n}\n.spotify-connect-section {\n display: flex;\n justify-content: center;\n padding: 2rem;\n}\n.spotify-tab-row {\n display: flex;\n align-items: stretch;\n border-bottom: 1px solid #e5e7eb;\n margin-bottom: 1rem;\n}\n.spotify-tabs {\n display: flex;\n gap: 0.25rem;\n flex: 1;\n}\n.spotify-tab {\n background: none;\n border: none;\n padding: 0.5rem 1rem;\n cursor: pointer;\n font-family: inherit;\n font-size: 0.875rem;\n color: #6b7280;\n border-bottom: 2px solid transparent;\n}\n.spotify-tab-active {\n color: #1ed760;\n border-bottom-color: #1ed760;\n font-weight: 500;\n}\n.spotify-refresh {\n background: none;\n border: none;\n color: #6b7280;\n cursor: pointer;\n font-size: 0.75rem;\n padding: 0 0.5rem;\n}\n.spotify-list {\n list-style: none;\n padding: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n}\n.spotify-track-row,\n.spotify-playlist-row {\n border-radius: 0.375rem;\n}\n.spotify-track-row:hover,\n.spotify-playlist-row:hover {\n background: #f5f5f5;\n}\n.spotify-track-link {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.5rem;\n background: none;\n border: none;\n cursor: pointer;\n text-align: left;\n font-family: inherit;\n}\n.spotify-cover {\n width: 2.5rem;\n height: 2.5rem;\n border-radius: 0.25rem;\n object-fit: cover;\n flex-shrink: 0;\n}\n.spotify-track-meta {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n min-width: 0;\n}\n.spotify-track-name {\n font-weight: 500;\n font-size: 0.875rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.spotify-track-artists {\n font-size: 0.75rem;\n color: #6b7280;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.spotify-track-album {\n font-size: 0.75rem;\n color: #9ca3af;\n}\n.spotify-track-duration {\n font-size: 0.75rem;\n color: #9ca3af;\n font-variant-numeric: tabular-nums;\n}\n.spotify-now-playing {\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n padding: 1rem;\n}\n.spotify-search-form {\n display: flex;\n gap: 0.5rem;\n margin-bottom: 1rem;\n}\n.spotify-search-results {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n.spotify-search-section h3 {\n font-size: 0.875rem;\n font-weight: 600;\n color: #6b7280;\n margin: 0 0 0.25rem;\n padding: 0;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n}\n.spotify-player,\n.spotify-player-locked,\n.spotify-devices {\n margin-top: 1rem;\n padding: 0.75rem 1rem;\n border: 1px solid #e5e7eb;\n border-radius: 0.5rem;\n}\n.spotify-player-locked {\n background: #fafafa;\n color: #6b7280;\n}\n.spotify-player h3,\n.spotify-player-locked h3,\n.spotify-devices h3 {\n font-size: 0.875rem;\n font-weight: 600;\n margin: 0 0 0.5rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: #6b7280;\n}\n.spotify-player-buttons {\n display: flex;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n}\n.spotify-player-btn {\n flex: 1;\n padding: 0.5rem;\n background: #1ed760;\n color: white;\n border: none;\n border-radius: 0.375rem;\n font-size: 1rem;\n cursor: pointer;\n}\n.spotify-player-btn:disabled {\n background: #d1d5db;\n cursor: not-allowed;\n}\n.spotify-player-volume {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-size: 0.875rem;\n}\n.spotify-player-volume input[type=\"range\"] {\n flex: 1;\n}\n.spotify-device-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.375rem 0.5rem;\n font-size: 0.875rem;\n}\n.spotify-device-name {\n flex: 1;\n font-weight: 500;\n}\n.spotify-device-type {\n color: #6b7280;\n font-size: 0.75rem;\n}\n.spotify-device-active {\n background: #1ed760;\n color: white;\n padding: 0 0.5rem;\n border-radius: 9999px;\n font-size: 0.75rem;\n}\n.spotify-device-transfer {\n background: none;\n border: 1px solid #d1d5db;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n cursor: pointer;\n font-size: 0.75rem;\n}\n.spotify-now-cover {\n width: 4rem;\n height: 4rem;\n border-radius: 0.375rem;\n}\n.spotify-empty,\n.spotify-loading {\n color: #6b7280;\n font-size: 0.875rem;\n text-align: center;\n padding: 2rem;\n}\n.spotify-error {\n color: #dc2626;\n font-size: 0.875rem;\n padding: 0.5rem;\n background: #fef2f2;\n border-radius: 0.375rem;\n}\n.spotify-retry {\n background: none;\n border: 1px solid currentColor;\n border-radius: 0.25rem;\n padding: 0.125rem 0.5rem;\n margin-left: 0.5rem;\n cursor: pointer;\n color: inherit;\n font-size: inherit;\n}\n</style>\n","<script setup lang=\"ts\">\n// Preview shown inline in the chat thread (alongside the LLM's text\n// response) when the LLM calls one of `manageSpotify`'s read kinds.\n// The full View opens on click via the parent thread's standard\n// \"open in canvas\" affordance — Preview just gives a glanceable\n// summary so the user knows what data was returned without needing\n// to expand the canvas.\n\nimport { computed } from \"vue\";\nimport { useT } from \"./lang\";\nimport type { NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SearchResult } from \"./types\";\n\n// Exported because `vite-plugin-dts` rolls Preview into\n// `dist/vue.d.ts` via the `plugin = { previewComponent: Preview }`\n// re-export in `vue.ts`. Without `export`, the inferred component\n// type names this interface as a type the public surface can't see\n// → TS4023 (same fix bookmarks-plugin's View.vue carries).\nexport interface Props {\n selectedResult: {\n ok?: boolean;\n data?:\n | NormalisedTrack[]\n | NormalisedPlaylist[]\n | RecentlyPlayedItem[]\n | NormalisedTrack\n | SearchResult\n | null\n | { connected?: boolean; clientIdConfigured?: boolean };\n error?: string;\n message?: string;\n };\n}\nconst props = defineProps<Props>();\nconst t = useT();\n\nconst summary = computed<string>(() => {\n const result = props.selectedResult;\n // `ok` is optional on the props (selectedResult is whatever the\n // last tool call returned) — only treat an explicit `false` as\n // failure. An undefined `ok` typically means \"no call yet\" or\n // \"non-listening kind whose response we don't recognise\"; fall\n // through to the generic summary instead of misrendering as an\n // error (Sourcery review on PR #1166).\n if (result.ok === false) return result.message ?? t.value.notConnected;\n const data = result.data;\n if (Array.isArray(data)) return summariseArray(data);\n if (data === null) return t.value.emptyNowPlaying;\n if (data && typeof data === \"object\" && \"connected\" in data) {\n return data.connected ? t.value.connected : data.clientIdConfigured ? t.value.notConnected : t.value.notConfigured;\n }\n // SearchResult is a per-category grouped object — no `name`, no\n // `connected`. Tally the totals so the chip reads e.g.\n // \"5 tracks · 2 artists\".\n if (data && typeof data === \"object\" && isSearchResult(data)) {\n return summariseSearchResult(data);\n }\n if (data && typeof data === \"object\" && \"name\" in data) {\n return (data as NormalisedTrack).name;\n }\n return t.value.previewSummary;\n});\n\nfunction isSearchResult(value: object): value is SearchResult {\n return \"tracks\" in value || \"artists\" in value || \"albums\" in value || \"playlists\" in value;\n}\n\nfunction summariseSearchResult(result: SearchResult): string {\n const parts: string[] = [];\n if (result.tracks?.length) parts.push(`${result.tracks.length} ${t.value.searchTracks}`);\n if (result.artists?.length) parts.push(`${result.artists.length} ${t.value.searchArtists}`);\n if (result.albums?.length) parts.push(`${result.albums.length} ${t.value.searchAlbums}`);\n if (result.playlists?.length) parts.push(`${result.playlists.length} ${t.value.searchPlaylists}`);\n return parts.length > 0 ? parts.join(\" · \") : t.value.searchEmpty;\n}\n\n// Different listening kinds carry different element shapes; pick the\n// label that matches the array's element type so a 5-playlist result\n// doesn't read as \"5 tracks\" (CodeRabbit review on PR #1166).\nfunction summariseArray(data: NormalisedTrack[] | NormalisedPlaylist[] | RecentlyPlayedItem[]): string {\n if (data.length === 0) return t.value.empty;\n const head = data[0];\n if (\"trackCount\" in head) return `${data.length} ${t.value.tabPlaylists}`;\n if (\"playedAt\" in head) return `${data.length} ${t.value.tabRecent}`;\n return `${data.length} ${t.value.tracksCount}`;\n}\n</script>\n\n<template>\n <div class=\"spotify-preview\">\n <span class=\"spotify-preview-icon\" aria-hidden=\"true\">♪</span>\n <span class=\"spotify-preview-label\">{{ t.previewSummary }}</span>\n <span class=\"spotify-preview-summary\">{{ summary }}</span>\n </div>\n</template>\n\n<style scoped>\n.spotify-preview {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.375rem 0.75rem;\n border-radius: 9999px;\n background: #f5f5f5;\n font-size: 0.875rem;\n}\n.spotify-preview-icon {\n color: #1ed760;\n font-weight: 600;\n}\n.spotify-preview-label {\n font-weight: 500;\n}\n.spotify-preview-summary {\n color: #6b7280;\n font-size: 0.75rem;\n}\n</style>\n","<script setup lang=\"ts\">\n// Preview shown inline in the chat thread (alongside the LLM's text\n// response) when the LLM calls one of `manageSpotify`'s read kinds.\n// The full View opens on click via the parent thread's standard\n// \"open in canvas\" affordance — Preview just gives a glanceable\n// summary so the user knows what data was returned without needing\n// to expand the canvas.\n\nimport { computed } from \"vue\";\nimport { useT } from \"./lang\";\nimport type { NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SearchResult } from \"./types\";\n\n// Exported because `vite-plugin-dts` rolls Preview into\n// `dist/vue.d.ts` via the `plugin = { previewComponent: Preview }`\n// re-export in `vue.ts`. Without `export`, the inferred component\n// type names this interface as a type the public surface can't see\n// → TS4023 (same fix bookmarks-plugin's View.vue carries).\nexport interface Props {\n selectedResult: {\n ok?: boolean;\n data?:\n | NormalisedTrack[]\n | NormalisedPlaylist[]\n | RecentlyPlayedItem[]\n | NormalisedTrack\n | SearchResult\n | null\n | { connected?: boolean; clientIdConfigured?: boolean };\n error?: string;\n message?: string;\n };\n}\nconst props = defineProps<Props>();\nconst t = useT();\n\nconst summary = computed<string>(() => {\n const result = props.selectedResult;\n // `ok` is optional on the props (selectedResult is whatever the\n // last tool call returned) — only treat an explicit `false` as\n // failure. An undefined `ok` typically means \"no call yet\" or\n // \"non-listening kind whose response we don't recognise\"; fall\n // through to the generic summary instead of misrendering as an\n // error (Sourcery review on PR #1166).\n if (result.ok === false) return result.message ?? t.value.notConnected;\n const data = result.data;\n if (Array.isArray(data)) return summariseArray(data);\n if (data === null) return t.value.emptyNowPlaying;\n if (data && typeof data === \"object\" && \"connected\" in data) {\n return data.connected ? t.value.connected : data.clientIdConfigured ? t.value.notConnected : t.value.notConfigured;\n }\n // SearchResult is a per-category grouped object — no `name`, no\n // `connected`. Tally the totals so the chip reads e.g.\n // \"5 tracks · 2 artists\".\n if (data && typeof data === \"object\" && isSearchResult(data)) {\n return summariseSearchResult(data);\n }\n if (data && typeof data === \"object\" && \"name\" in data) {\n return (data as NormalisedTrack).name;\n }\n return t.value.previewSummary;\n});\n\nfunction isSearchResult(value: object): value is SearchResult {\n return \"tracks\" in value || \"artists\" in value || \"albums\" in value || \"playlists\" in value;\n}\n\nfunction summariseSearchResult(result: SearchResult): string {\n const parts: string[] = [];\n if (result.tracks?.length) parts.push(`${result.tracks.length} ${t.value.searchTracks}`);\n if (result.artists?.length) parts.push(`${result.artists.length} ${t.value.searchArtists}`);\n if (result.albums?.length) parts.push(`${result.albums.length} ${t.value.searchAlbums}`);\n if (result.playlists?.length) parts.push(`${result.playlists.length} ${t.value.searchPlaylists}`);\n return parts.length > 0 ? parts.join(\" · \") : t.value.searchEmpty;\n}\n\n// Different listening kinds carry different element shapes; pick the\n// label that matches the array's element type so a 5-playlist result\n// doesn't read as \"5 tracks\" (CodeRabbit review on PR #1166).\nfunction summariseArray(data: NormalisedTrack[] | NormalisedPlaylist[] | RecentlyPlayedItem[]): string {\n if (data.length === 0) return t.value.empty;\n const head = data[0];\n if (\"trackCount\" in head) return `${data.length} ${t.value.tabPlaylists}`;\n if (\"playedAt\" in head) return `${data.length} ${t.value.tabRecent}`;\n return `${data.length} ${t.value.tracksCount}`;\n}\n</script>\n\n<template>\n <div class=\"spotify-preview\">\n <span class=\"spotify-preview-icon\" aria-hidden=\"true\">♪</span>\n <span class=\"spotify-preview-label\">{{ t.previewSummary }}</span>\n <span class=\"spotify-preview-summary\">{{ summary }}</span>\n </div>\n</template>\n\n<style scoped>\n.spotify-preview {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.375rem 0.75rem;\n border-radius: 9999px;\n background: #f5f5f5;\n font-size: 0.875rem;\n}\n.spotify-preview-icon {\n color: #1ed760;\n font-weight: 600;\n}\n.spotify-preview-label {\n font-weight: 500;\n}\n.spotify-preview-summary {\n color: #6b7280;\n font-size: 0.75rem;\n}\n</style>\n","// Vue entry — exports the canvas + preview components the host's\n// runtime plugin loader dynamic-imports as `dist/vue.js`. Same shape\n// as bookmarks-plugin / todo-plugin so the host's loader registers\n// them without special-casing.\n\nimport View from \"./View.vue\";\nimport Preview from \"./Preview.vue\";\nimport { TOOL_DEFINITION } from \"./definition\";\n\nexport const plugin = {\n toolDefinition: TOOL_DEFINITION,\n viewComponent: View,\n previewComponent: Preview,\n};\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulmoclaude/spotify-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only Spotify integration for MulmoClaude — Liked Songs / playlists / recently played, OAuth via PKCE. Built as a runtime plugin (issue #1162).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./vue": {
|
|
15
|
+
"types": "./dist/vue.d.ts",
|
|
16
|
+
"import": "./dist/vue.js",
|
|
17
|
+
"require": "./dist/vue.js",
|
|
18
|
+
"default": "./dist/vue.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "vite build",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "echo 'no in-package tests; covered by host tests under test/plugins/spotify/'"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"gui-chat-protocol": "^0.3.0",
|
|
31
|
+
"vue": "^3.5.0",
|
|
32
|
+
"zod": "^4.3.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"vite": "^8.0.13",
|
|
38
|
+
"vite-plugin-dts": "^5.0.0",
|
|
39
|
+
"vue": "^3.5.34",
|
|
40
|
+
"zod": "^4.4.3"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|