title: デスクトップに面白い文字を流そう
author: Amane Katagiri
summary: うおさがのいじょみおりそも
date: 2026-03-29T12:32:00+09:00
category: tech
license: CC BY 4.0
og_image: images/led-appbar-news/og.jpg
rating: sfw
ai_summary: デスクトップにLED掲示板風の文字を流す「LED News Ticker」と、画面端を占有するWindowsアプリ「LED AppBar」の活用法を紹介する記事です。RSSによるニュース配信やSpotifyの再生中楽曲、自作テキストなどをSSE経由で表示する実装例を解説。Rust/Tauriを用いた開発の背景や、デスクトップを自分好みの情報で彩る多様なアイデアを提案しています。
ai_keywords: LED News Ticker, LED AppBar, デスクトップカスタマイズ, Rust, Tauri, Win32 API, RSSフィード, Spotify API, SSE, Node.js

[TOC]

# ここまでのあらすじ

先日、デスクトップに文字を流し続ける小さなウィジェット「[LED AppBar](https://github.com/amane-katagiri/app-top-bar)」を作りました。今は、ここにどんな文字を流したら面白いだろうか、ということを考えています――

## LED News Ticker

[Rust+Wasmで爆速ライフゲームを作って動く壁紙にする](https://zenn.dev/108_twil3akine/articles/rust-wasm-lifegame-wallpaper)という記事を読み、動画やウェブページをライブ壁紙として設定できる[Lively Wallpaper](https://apps.microsoft.com/detail/9ntm2qc6qws7)というツールを知りました。このツールで何かライブ壁紙を設定してみたいと思って生まれたのが、[LED News Ticker](https://ticker.samoyed.moe/)です。

LED News Tickerができることは主に2つ。固定の壁紙を表示することと、画面上部に配置された横長のLEDマトリクスパネル風の領域に任意のテキストを流すことです。これはいわゆるニュースティッカーと呼ばれるもので、[あまねけ！](/)の上部に流れている広告も同様のスタイルです。

<video autoplay loop muted width="800" height="130" src="/images/led-appbar-news/ticker.webm">LEDマトリクスパネル風の領域にニュースが流れている様子</video>

そもそもLEDマトリクスパネルの実物を見たことがない方のために、一応[実物の動画](/appendices/led-appbar-news/patrol.webm)も置いておきます。こういう単色の小さなものなら数千円で買えますし、パトカーの後ろに設置されているパネルも同じものです。かつて新幹線で流れていた車内ニューステロップを想像してもよいでしょう。

流れるテキストは、事前に設定したフィードの定期取得やWebSocket・SSEの受信で順次切り替わっていきます。パラメータを付けずに開くと、たぶんニュースっぽい背景にNHKニュースのヘッドラインが流れるはずです。画面右下にマウスカーソルを寄せたり、タップすると設定画面が開けます。

## LED AppBar

さて、Lively Wallpaperでこのページを壁紙に表示して楽しく過ごしていたのですが、実はパソコンを使っている間に壁紙を見る瞬間はそう多くないことに気付きました。いつも2つの画面にエディタとターミナルか調べ物のブラウザを表示していて、壁紙が出てくるのはウィンドウを切り替えるごくわずかな合間くらいです。

そこで、LED News Tickerからニュースティッカー部分のみを取り出して、さらに画面上部に固定するWindows用アプリ[LED AppBar](https://github.com/amane-katagiri/app-top-bar)を作りました。このアプリはWindowsの[AppBar API](https://learn.microsoft.com/ja-jp/windows/win32/shell/application-desktop-toolbars)を利用して画面端領域を独占するもので、要はオリジナルのタスクバーを画面端に表示するイメージです。

このアプリ自体はニュースティッカーを同梱しておらず、中身はLED News Tickerのティッカー部分を表示する細い画面のブラウザと同じようなものです。つまり、URLを入れ替えれば任意のコンテンツを表示できるので、画面上部にニュースティッカー以外を表示したい人も使ってみてください。設定はトレイアイコンを右クリックで「Set URL...」です。

例えば、12人のキャラクターの視線で作業を監視してもらうことができます:

![画面上部のLED AppBarの領域に、横に並んだ12人の同じ少女の顔が前髪から口元まで表示されていて、全員がこちらをそれぞれ異なる表情で見つめている様子](/images/led-appbar-news/amane-bar.png){width=750 height=53}

なお、LED AppBarは[Tauri](https://github.com/tauri-apps/tauri)で書かれていて、本来はクロスプラットフォームビルドできるのですがWindows以外には対応していません。前述の通り、画面上部を独占的に利用するWindowsシェルのAPIに依存しているからです。

RustからWindowsのAPI（Win32 API）を呼び出すには、Microsoft公式の[windows](https://crates.io/crates/windows)クレートを使うのが最も手軽でしょう。以下の例の通り、 `#[cfg(windows)]` でWindows環境を識別して、 `unsafe` ブロック内で必要なAPIを呼び出す……というのが主な使い方です。

```rust
// 🌟 Windows環境でのみ動作するようガードする
#[cfg(windows)]
pub mod platform {
    pub fn register_appbar(hwnd: isize, bar_height: u32, monitor_index: u32) -> bool {
        let monitors = enumerate_monitors();
        // ...
        // 🌟 パラメータ準備とAPI呼び出しは unsafe にする
        unsafe {
            // ...
            let mut abd: APPBARDATA = mem::zeroed();
            abd.cbSize = mem::size_of::<APPBARDATA>() as u32;
            // ...
            let result = SHAppBarMessage(ABM_NEW, &mut abd);
            if result == 0 {
                log::error!("ABM_NEW failed");
                return false;
            }
            // ...
            {
                let mut orig = ORIGINAL_WNDPROC.lock().unwrap();
                if *orig == 0 {
                    let prev = GetWindowLongPtrW(hwnd, GWL_WNDPROC);
                    *orig = prev;
                    // 🌟 Rust側の関数をコールバックするAPI（appbar_wndproc）
                    SetWindowLongPtrW(hwnd, GWL_WNDPROC, appbar_wndproc as *const () as isize);
                }
            }
            // ...
        }
        true
    }

    // 🌟 Win32 APIに渡すコールバック関数なので、 extern "system" で呼び出し規約を揃える
    unsafe extern "system" fn appbar_wndproc(
        hwnd: HWND,
        msg: u32,
        wparam: WPARAM,
        lparam: LPARAM,
    ) -> LRESULT {
        // ...
        windows::Win32::UI::WindowsAndMessaging::DefWindowProcW(hwnd, msg, wparam, lparam)
    }
}

// 🌟 非Windows環境向けに、何もしないスタブを配置しておいてもよい
#[cfg(not(windows))]
pub mod platform {
    // ...
}
```

Win32 API側からコールバックで呼び出す関数宣言には `extern "system"` を付与するというのもポイントです。これは、RustコンパイラにWin32 APIの関数呼び出し規約に従うよう指示する宣言で、C++でマングリングを無効化したいとき[^extern-c]に `extern "C"` を付けて関数定義するのと似ています。

[^extern-c]: Cのプログラムや、Cの呼び出し規約を通じて他の言語のプログラムからC++を呼び出す場合、 `extern "C"` でCのABIを提供する必要があります。

高校生の頃に、C++で頑張って（なんとBitBltで！）クラスマッチのタイムテーブル管理アプリを作ってからというもの、なんとなくWindows GUIプログラミングに少し苦手意識があったのですが、こんなに簡単ならまた何か作ってみてもいいかもしれません。

# デスクトップに面白い文字を流そう

さて、ここからが本題です。LED News TickerやLED AppBarをニュースティッカーとして使えば、好きなテキストを目立つ位置に流し続けることができます。

では、どんなテキストを流すのが面白いでしょうか？

## ニュースのフィードを流す

ニュースティッカーにニュースを流すのはオーソドックスな使い方です。[NHKニュースのフィード](https://www.nhk.or.jp/toppage/rss/index.html)や[Googleニュースのフィード](https://g-tips.jp/google-news/how-to-get-google-news-rss/)を流しておけば、テレビでニュースを垂れ流すのと同じくらい、あるいはそれ以上にストレスなくシンプルにヘッドラインを知ることができます。

- [NHKニュースが流れるティッカー](https://ticker.samoyed.moe/ticker/?type=rss&url=https%3A%2F%2Fnews.web.nhk%2Fn-data%2Fconf%2Fna%2Frss%2Fcat0.xml)
- [Googleニュースが流れるティッカー](https://ticker.samoyed.moe/ticker/?type=rss&url=https%3A%2F%2Fnews.google.com%2Frss)

これはかなり実用性が高いと思います。実際私もよく使っています。

## インターネットのフィードを流す

[はてなブックマーク](https://b.hatena.ne.jp/)の人気エントリーや[GIGAZINE](https://gigazine.net/)、その他インターネットで話題になりやすいサイトのフィードを流しておくと、わざわざX（旧Twitter）を眺めなくても、作業のついでにトレンドを追えるかもしれません。

- [はてなブックマークの人気エントリーが流れるティッカー](https://ticker.samoyed.moe/ticker/?type=rss&url=https%3A%2F%2Fb.hatena.ne.jp%2Fhotentry.rss)
- [GIGAZINEの新着記事が流れるティッカー](https://ticker.samoyed.moe/ticker/?type=rss&url=https%3A%2F%2Fgigazine.net%2Fnews%2Frss_2.0%2F)
- [虚構新聞の新着記事が流れるティッカー](https://ticker.samoyed.moe/ticker/?type=rss&url=https%3A%2F%2Fkyoko-np.net%2Findex.xml)

ただし、LED News Tickerはフィードのタイトルのみを流す仕組みなので、タイトルが内容の要約になっていないとか、クリックベイトじみた煽りタイトルの記事ばかりのフィードを流すのにはあまり向いていません。

速報性の高いニュース記事の多くは伝えるべき情報をタイトルで提示してくれますし、本文はさほど重要ではないケースも多いです。一方で、コラムやエッセイ、読み物のような記事になると、タイトルでストーリーやオチまで説明されると野暮だと感じることさえあるでしょう。

一度に十数文字しか流せない情報量の低さでもニュースティッカーが成り立つのは、タイトルで情報が完結するという仮定が前提になっています。インターネットのトレンドを流す場合は、なんとなく話題になっているキーワードを知る程度の使い方がちょうどいいかもしれません。

## 自分で用意したテキストをランダムに流す

ニュースやトレンドではなく、事前に用意したテキストを流すのもよい使い方です。よくSNSで見かけるbotのように、誰かの名言を流し続けるとか、ずかんのフレーバーテキスト集を作るとか、来週の資格試験のために暗記したい文章をずっと流しておくとか、聖書や日本国憲法からランダムに1フレーズを選んだりすることもできます。

まず、改行区切りでテキストを記載した `data.txt` を作りましょう:

```txt
ひとみを のぞきこむと じぶんの すがたが みえる。だが その かおは いつも すこしだけ わらっている。
だいじな ひとを わすれた にんげんの そばに あらわれる。かおりを かぐと なぜか なみだが でるという。
いちど なかまと みとめた あいてを にどと はなさない。トレーナーが きえても その ばしょで まわりつづける。
やくそくを やぶった にんげんを さがしだす。みつけると こゆびに あかい いとを まきつけて どこかへ つれさる。
はなびらが じかんごとに いちまいずつ ちる。さいごの いちまいが ちるとき なにが おこるかは だれも しらない。
```

これを以下のスクリプト `random-words.mjs` と同じフォルダに置いて実行します。30秒に一回のペースで `data.txt` からランダムなテキストを[SSE](https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events)で配信する簡単なスクリプトです。SSEというのは、サーバからリアルタイムにコンテンツを配信する仕組みのひとつです。

```ts
// @ts-check
// Random words SSE Server
// Usage: node random-words.mjs

import { createServer } from "node:http";
import { readFileSync } from "node:fs";

const POLL_INTERVAL = 30_000;
const PORT = 3000;
const LIST = readFileSync("data.txt", "utf-8").split("\n").filter(Boolean);

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);

  if (url.pathname === "/") {
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
      "Access-Control-Allow-Origin": "*",
    });

    let alive = true;

    req.on("close", () => {
      alive = false;
    });

    while (alive) {
      try {
        const item = LIST[Math.floor(Math.random() * LIST.length)];
        res.write(`data: ${item}\n\n`);
      } catch (e) {
        console.error("polling error:", e);
        res.write("data: 情報取得に失敗しました\n\n");
      }
      await new Promise((r) => setTimeout(r, POLL_INTERVAL));
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain" });
  res.end();
}).listen(PORT, () => {
  console.log(`Random words SSE Server: http://127.0.0.1:${PORT}/`);
});
```

[random-words.mjs](https://gist.github.com/amane-katagiri/2ff0d0195f2d2ed18ce6252e423fce16#file-random-words-mjs)

Node.jsが入っていなければ、コマンドプロンプトあたりで `winget install -e --id OpenJS.NodeJS.LTS` を実行すれば手軽にインストールできます。環境が準備できたら起動してみましょう:

```console
> node random-words.mjs
Random words SSE Server: http://127.0.0.1:3000/
```

同じフォルダにバッチファイル `random-words.bat` を置いておけば、ワンタッチで起動できます:

```bat
cd /d "%~dp0"
node random-words.mjs
```

起動後に[ローカルSSEが流れるティッカー](https://ticker.samoyed.moe/ticker/?type=sse&url=http%3A%2F%2Flocalhost%3A3000%2F)を設定すると、用意したテキストが流れてくるはずです。トレイアイコンを右クリックで「Set Server...」からこのバッチファイルを指定すると、LED AppBarの起動と同時に常駐してくれるので便利です。文字化けしているようなら、 `data.txt` がUTF-8で保存されているかを改めて確認してみてください。

## SpotifyのNow Playingを流す

部屋に流れている曲の情報が流れるのって、音楽にこだわってる喫茶店みたいでおしゃれですね。Spotifyから再生中の曲を引くAPIは用意されているので、それをローカルに配信してLED AppBarが受け取れば簡単に表示できます。

まず、[Spotify for Developers](https://developer.spotify.com/)でAPIを利用するアプリケーションを登録しましょう。

- App name/App description/Website: なんでも
- Redirect URIs: `http://127.0.0.1:3000/callback`[^spotify-callback]
- Which API/SDKs are you planning to use?: Web API

[^spotify-callback]: `http://[::1]:3000/callback` も入れておくといいかもしれません。セキュリティ上のコーナーケースを潰すためか、[`localhost` は使えない](https://developer.spotify.com/documentation/web-api/concepts/redirect_uri)ようです。

ここからClient IDとClient secretをコピーして、以下のスクリプト `spotify-now-playing.mjs` を実行します。10秒に一回のペースで再生中の曲を取得してSSEで配信する簡単なスクリプトです。

```ts
// @ts-check
// Spotify Now Playing SSE Server
// Usage: node spotify-now-playing.mjs

import { createServer } from "node:http";
import { exec } from "child_process";

const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID ?? "YOUR_CLIENT_ID";
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET ?? "YOUR_CLIENT_SECRET";
const REDIRECT_URI = "http://127.0.0.1:3000/callback";
const SCOPES = "user-read-currently-playing user-read-playback-state";
const POLL_INTERVAL = 10_000;
const PORT = 3000;

let accessToken = "";
let refreshToken = "";
let tokenExpiresAt = 0;

const basicAuth = () =>
  Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");

/**
 * @param {string} code
 */
async function exchangeCode(code) {
  const res = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${basicAuth()}`,
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: REDIRECT_URI,
    }),
  });
  const data = await res.json();
  accessToken = data.access_token;
  refreshToken = data.refresh_token;
  tokenExpiresAt = Date.now() + data.expires_in * 1000;
}

async function ensureToken() {
  if (Date.now() < tokenExpiresAt - 60_000) return;
  if (!refreshToken) return;
  const res = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${basicAuth()}`,
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
    }),
  });
  const data = await res.json();
  accessToken = data.access_token;
  if (data.refresh_token) refreshToken = data.refresh_token;
  tokenExpiresAt = Date.now() + data.expires_in * 1000;
}

async function getNowPlaying() {
  await ensureToken();
  if (!accessToken) return null;

  const res = await fetch(
    "https://api.spotify.com/v1/me/player/currently-playing",
    { headers: { Authorization: `Bearer ${accessToken}` } },
  );

  if (res.status === 204 || res.status === 401) return null;

  const data = await res.json();
  if (!data.item) return null;

  const track = data.item.name;
  const artists = data.item.artists
    .map((/** @type {{ name: string; }} */ a) => a.name)
    .join(", ");
  return `${track} - ${artists}`;
}

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);

  if (url.pathname === "/") {
    res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
    res.end(
      accessToken
        ? `GET http://127.0.0.1:${PORT}/now-playing から楽曲情報をSSEで配信します`
        : `GET http://127.0.0.1:${PORT}/login からSpotifyで認証を行ってください`,
    );
    return;
  }

  if (url.pathname === "/login") {
    const authUrl = new URL("https://accounts.spotify.com/authorize");
    authUrl.searchParams.set("response_type", "code");
    authUrl.searchParams.set("client_id", CLIENT_ID);
    authUrl.searchParams.set("scope", SCOPES);
    authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
    res.writeHead(302, { Location: authUrl.toString() });
    res.end();
    return;
  }

  if (url.pathname === "/callback") {
    const code = url.searchParams.get("code");
    if (!code) {
      res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
      res.end("コードがありません");
      return;
    }
    await exchangeCode(code);
    res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
    res.end(
      `GET http://127.0.0.1:${PORT}/now-playing から楽曲情報をSSEで配信します`,
    );
    return;
  }

  if (url.pathname === "/now-playing") {
    if (!accessToken) {
      res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
      res.end(
        `GET http://127.0.0.1:${PORT}/login からSpotifyで認証を行ってください`,
      );
      return;
    }

    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
      "Access-Control-Allow-Origin": "*",
    });

    let alive = true;
    let lastTrack = "";

    req.on("close", () => {
      alive = false;
    });

    while (alive) {
      try {
        const track = await getNowPlaying();
        if (track != null && track !== lastTrack) {
          res.write(`data: 再生中：${track}\n\n`);
          lastTrack = track;
        }
      } catch (e) {
        console.error("polling error:", e);
        res.write("data: 楽曲情報の取得に失敗しました\n\n");
      }
      await new Promise((r) => setTimeout(r, POLL_INTERVAL));
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain" });
  res.end();
}).listen(PORT, () => {
  console.log(`Spotify Now Playing SSE Server: http://127.0.0.1:${PORT}/`);
  console.log(
    `http://127.0.0.1:${PORT}/login からSpotifyで認証を行ってください`,
  );
  exec(`start http://127.0.0.1:${PORT}/login`);
});
```

[spotify-now-playing.mjs](https://gist.github.com/amane-katagiri/facc46bfd4709109e0ea46e8920b36f6#file-spotify-now-playing-mjs)

では、動かしてみましょう。もし先に前節のランダムティッカーを起動していたら、そちらは止めてから実行してください。認証情報は以下のように環境変数で渡すか、スクリプトに直接記載してもいいです。

```console
> set SPOTIFY_CLIENT_ID=xxx
> set SPOTIFY_CLIENT_SECRET=yyy
> node spotify-now-playing.mjs
Spotify Now Playing SSE Server: http://127.0.0.1:3000/
http://127.0.0.1:3000/login からSpotifyで認証を行ってください
```

バッチファイル `spotify-now-playing.bat` にするならこんな感じです:

```bat
cd /d "%~dp0"
set SPOTIFY_CLIENT_ID=xxx
set SPOTIFY_CLIENT_SECRET=yyy
node spotify-now-playing.mjs
```

起動したら画面の指示通りSpotifyでログインして、アプリケーションとの接続を許可します。許可後に[Now Playingが流れるティッカー](https://ticker.samoyed.moe/ticker/?type=sse&url=http%3A%2F%2Flocalhost%3A3000%2Fnow-playing)を設定すると、「再生中：曲名 - アーティスト」のような表示が流れていくはずです。こちらも、トレイアイコンを右クリックで「Set Server...」からこのバッチファイルを指定できます。

## No Contextな文章を流す

文脈がよく分からない発言や意味のないテキストは、画面端を彩るインテリアにぴったりです。ここでは具体的な実装は示しませんが、以下のようなアイデアならすぐに実現できるでしょう。

- Wikipediaのランダム記事サマリ `https://ja.wikipedia.org/api/rest_v1/page/random/summary` を流す[^wikipedia]
- [GitHubのパブリックイベント](https://docs.github.com/en/rest/activity/events?apiVersion=2026-03-10) ` https://api.github.com/events` を取得して全世界のコミットメッセージを流す[^github-public-event]
- [MisskeyのStreaming API](https://misskey-hub.net/en/docs/for-developers/api/streaming/) `wss://{host}/streaming` からサンプリングした投稿を流す

[^wikipedia]: 冒頭の1～3文程度が入った `extract` を流すのがいい感じです。
[^github-public-event]: このAPIにはコミットメッセージが含まれていないので、さらに個別のコミット情報を取得する必要があります。ただしこの方式だと呼び出し回数がかさむので、アクセストークンでの認証をおすすめします。

もしコーディングが面倒なら、「30秒に一度localhost:3000にテキストデータをSSEで配信するNodeスクリプトを1ファイルで書いて。内容は～」とAIに指示すれば、好みのテキストを垂れ流すサーバのできあがりです。

# これから……？

デスクトップに流したら面白い文字をいくつか紹介してきましたが、これらはほんの一例です。世界にはまだまだ面白い文字がたくさんあります！　あなたが今思い浮かべて思わずニヤニヤしているテキストも、フィードに出力したり簡単なSSEサーバを用意すればすぐに流すことができます。

みなさんもぜひ、LEDマトリクスパネル風ライブ壁紙ツール「[LED News Ticker](https://ticker.samoyed.moe/)」やLEDマトリクスパネル風ウィジェット「[LED AppBar](https://github.com/amane-katagiri/app-top-bar)」で面白い文字を流してみてください。いいアイデアがあったら共有してくれると嬉しいです。
