This site is a mirror of ama.ne.jp.

icon of Amane Katagiri デスクトップに面白い文字を流そう

ここまでのあらすじ

先日、デスクトップに文字を流し続ける小さなウィジェット「LED AppBar」を作りました。今は、ここにどんな文字を流したら面白いだろうか、ということを考えています――

LED News Ticker

Rust+Wasmで爆速ライフゲームを作って動く壁紙にするという記事を読み、動画やウェブページをライブ壁紙として設定できるLively Wallpaperというツールを知りました。このツールで何かライブ壁紙を設定してみたいと思って生まれたのが、LED News Tickerです。

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

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

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

LED AppBar

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

そこで、LED News Tickerからニュースティッカー部分のみを取り出して、さらに画面上部に固定するWindows用アプリLED AppBarを作りました。このアプリはWindowsのAppBar APIを利用して画面端領域を独占するもので、要はオリジナルのタスクバーを画面端に表示するイメージです。

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

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

画面上部のLED AppBarの領域に、横に並んだ12人の同じ少女の顔が前髪から口元まで表示されていて、全員がこちらをそれぞれ異なる表情で見つめている様子

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

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

// 🌟 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++でマングリングを無効化したいとき1extern "C" を付けて関数定義するのと似ています。

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

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

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

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

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

ニュースティッカーにニュースを流すのはオーソドックスな使い方です。NHKニュースのフィードGoogleニュースのフィードを流しておけば、テレビでニュースを垂れ流すのと同じくらい、あるいはそれ以上にストレスなくシンプルにヘッドラインを知ることができます。

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

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

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

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

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

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

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

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

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

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

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

// @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

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

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

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

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

起動後にローカルSSEが流れるティッカーを設定すると、用意したテキストが流れてくるはずです。トレイアイコンを右クリックで「Set Server...」からこのバッチファイルを指定すると、LED AppBarの起動と同時に常駐してくれるので便利です。文字化けしているようなら、 data.txt がUTF-8で保存されているかを改めて確認してみてください。

SpotifyのNow Playingを流す

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

まず、Spotify for DevelopersでAPIを利用するアプリケーションを登録しましょう。

  • App name/App description/Website: なんでも
  • Redirect URIs: http://127.0.0.1:3000/callback2
  • Which API/SDKs are you planning to use?: Web API

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

// @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

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

> 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 にするならこんな感じです:

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

起動したら画面の指示通りSpotifyでログインして、アプリケーションとの接続を許可します。許可後にNow Playingが流れるティッカーを設定すると、「再生中:曲名 - アーティスト」のような表示が流れていくはずです。こちらも、トレイアイコンを右クリックで「Set Server...」からこのバッチファイルを指定できます。

No Contextな文章を流す

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

  • Wikipediaのランダム記事サマリ https://ja.wikipedia.org/api/rest_v1/page/random/summary を流す3
  • GitHubのパブリックイベント https://api.github.com/events を取得して全世界のコミットメッセージを流す4
  • MisskeyのStreaming API wss://{host}/streaming からサンプリングした投稿を流す

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

これから……?

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

みなさんもぜひ、LEDマトリクスパネル風ライブ壁紙ツール「LED News Ticker」やLEDマトリクスパネル風ウィジェット「LED AppBar」で面白い文字を流してみてください。いいアイデアがあったら共有してくれると嬉しいです。


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

  2. http://[::1]:3000/callback も入れておくといいかもしれません。セキュリティ上のコーナーケースを潰すためか、localhost は使えないようです。 

  3. 冒頭の1~3文程度が入った extract を流すのがいい感じです。 

  4. このAPIにはコミットメッセージが含まれていないので、さらに個別のコミット情報を取得する必要があります。ただしこの方式だと呼び出し回数がかさむので、アクセストークンでの認証をおすすめします。 

「読んだ」を押すと、あなたがボタンを押した事実を明示的に通知してこのページに戻ります。このページに戻ってからブラウザの「戻る」ボタンを押すと、何度か同じページが表示されることがあります。