Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
13e583b
docs(nip): add kind 30427 album cover proposal
StateAntigen Sep 16, 2026
557b6e4
feat(cover): consume kind 30427 album cover events
StateAntigen Sep 16, 2026
ff5ccdc
feat(napstrfy): fetch album art from the host over Iroh
StateAntigen Sep 16, 2026
ba8e3e4
feat(napstrfy): expandable now-playing sheet
StateAntigen Sep 16, 2026
89ad5f4
feat(napstrfy): stop resolving album art from MusicBrainz
StateAntigen Sep 16, 2026
cc36fa3
fix(napstrfy): remember missing artwork only for the session
StateAntigen Sep 16, 2026
0208d79
feat(napstrfy): resolve album art only from Napstr cover events
StateAntigen Sep 17, 2026
397186e
feat(napstrfy): redesign the player around a drawer, shelves and a qu…
StateAntigen Sep 18, 2026
41d771a
feat(napstrfy): control the computer, lend read-only codes, report co…
StateAntigen Sep 18, 2026
9d067fb
fix(desktop): first compile of the mobile control and cover publishin…
StateAntigen Sep 18, 2026
86afa08
fix(desktop): keep the window's page out of runes mode
StateAntigen Sep 18, 2026
cd00ad7
chore(desktop): record the dependencies the cover lookups added
StateAntigen Sep 18, 2026
a5913cd
feat(desktop): find, fix and report album art, and queue only what a …
StateAntigen Sep 18, 2026
b5d7f70
feat(covers): tell the phone when the host finds new art
StateAntigen Sep 19, 2026
6986db8
feat(mobile): play a chosen track on the computer, and choose where i…
StateAntigen Sep 19, 2026
562cb63
chore(android): script the APK build, signing and verification
StateAntigen Sep 19, 2026
1689a72
chore(android): reset the Gradle daemon before its cache, and name th…
StateAntigen Sep 19, 2026
183aedf
feat(napstrfy): keep the chrome on screen, and turn notices into toasts
StateAntigen Sep 19, 2026
9c4d149
feat(napstrfy): play the computer's player through the one drawer and…
StateAntigen Sep 19, 2026
b7ec9dc
feat(napstrfy): wear the cover on the bar, simplify the playlist icon…
StateAntigen Sep 19, 2026
a876701
fix(napstrfy): keep carousel art off the edge, restore the drawer's d…
StateAntigen Sep 19, 2026
26f36fb
feat(napstrfy): cast glyph for the playback source buttons
StateAntigen Sep 19, 2026
d697f6b
chore: remove the stray deep-link page from the repository root
StateAntigen Sep 19, 2026
46ccd41
Merge upstream/main into feat/napstr-cover-30427
StateAntigen Sep 20, 2026
828f1c1
fix(build): find the Android toolchain instead of hardcoding it
StateAntigen Sep 20, 2026
6d3c569
test: stop asserting on snapshots that depend on layout timing
StateAntigen Sep 20, 2026
da06413
fix(napstrfy): step the volume only on a real raise or lower
StateAntigen Sep 20, 2026
3ed8a9c
feat(napstrfy): track menus, liked styling, album layout and napstrfy…
StateAntigen Sep 20, 2026
0f294b3
fix(napstrfy): hold the desktop overlays to the middle column
StateAntigen Sep 21, 2026
0f8891a
docs(nip): correct the 30427 kind-selection claim with a real census
StateAntigen Sep 21, 2026
0e0a2e7
docs(cover): bound the rendition a cover should arrive at
StateAntigen Sep 21, 2026
15e9e13
fix(napstrfy): make the liked page, the library rows and back behave
StateAntigen Sep 21, 2026
cbf55b7
fix(cover): publish the archive's own 1200-pixel rendition
StateAntigen Sep 21, 2026
9305340
fix(napstrfy): open the now-playing drawer on the thumbnail
StateAntigen Sep 21, 2026
b29aafd
fix(napstrfy): answer for back after every press, not only when the a…
StateAntigen Sep 21, 2026
e13c71c
feat(napstrfy): ask for and fetch the next track's artwork as it plays
StateAntigen Sep 21, 2026
f70267b
fix(napstrfy): draw the player bar, and the cover behind it, from the…
StateAntigen Sep 21, 2026
94bdfe2
feat(napstrfy): preload the next track's full cover, and upgrade the …
StateAntigen Sep 21, 2026
62561ad
fix(cover): find the art when the archive will not answer for the record
StateAntigen Sep 27, 2026
ca6403e
docs(cover): Apple names its rendition in the artwork URL
StateAntigen Sep 27, 2026
5f80646
feat(cover): a second source, a list of hosts, a log, and patience
StateAntigen Sep 27, 2026
87b4fcf
feat(cover): paste a link, and see what the lookups did
StateAntigen Sep 27, 2026
ba9b2cd
test(cover): wait out a busy MusicBrainz instead of failing on it
StateAntigen Sep 27, 2026
14361d5
Merge branch 'main' into feat/napstr-cover-30427
StateAntigen Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Shell scripts are executed inside the Linux build containers, which mount this
# working tree directly rather than checking it out themselves. With
# core.autocrlf=true a Windows working copy would hand those scripts CRLF
# endings, and bash stops on the second line of every one of them:
# set: pipefail\r: invalid option name
# Pinning them to LF keeps the mounted scripts runnable from any host.
*.sh text eol=lf
scripts/appimage/AppRun text eol=lf
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"chat.tools.terminal.autoApprove": {
"Get-Item": true,
"cargo build": true,
"git rev-parse": true
}
}
399 changes: 399 additions & 0 deletions NIP-NAPSTR-COVER.md

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,9 @@ discriminator. Defined requests are:
{"type":"transfers"}
{"type":"fetchAudio","fileId":"<fileId>"}
{"type":"available","fileIds":["<fileId>","<fileId>"]}
{"type":"albumCovers","keys":["<artist|album>"]}
{"type":"playback","command":{"type":"playTrack","fileId":"<fileId>","queue":["<fileId>"]}}
{"type":"playbackState"}
{"type":"status"}
{"type":"ping"}
```
Expand Down Expand Up @@ -736,6 +739,33 @@ increasing local-library revision. A companion MAY poll it and should reload
library pages only when it changes. The revision check carries no catalogue
rows and does not affect active audio streams.

It also contains `coverRevision`, which moves whenever the album art the
desktop would report changes: a claim that arrived from a relay, or art the
desktop resolved for itself. Artwork is requested album by album, so a
companion that caches covers — including the albums the desktop had none for —
MUST ask again about the ones it was told were missing when this changes,
because an album with no art a moment ago may have art now. A desktop that has
no cover support reports `0`, which a companion MUST read as "never
invalidated" rather than as a revision of zero.

`playback` drives the desktop's own player, and only a pairing with write access
may send it. Its `command.type` values are `play`, `pause`, `toggle`, `stop`,
`next`, `previous`, `seek` (`positionMs`), `volume` (`percent`, 0 to 100),
`repeat` (`mode` of `off`, `all` or `one`), `shuffle` (`enabled`), and
`playTrack`. Both `playback` and `playbackState` answer with a `playback`
response carrying the host's own state, which includes its `queueLen`,
`queueIndex`, `repeat` and `shuffle`.

`playTrack` is how a companion plays a chosen track on the desktop instead of on
itself. `fileId` is the track to begin on and `queue` is the list the companion
was showing, in order, at most 200 entries. The desktop adopts that list as its
queue, so "next" goes where the companion would have gone, but it plays only the
entries it actually holds — exactly as it does for its own search results — and
refuses a request whose track it does not have rather than playing something
else. Commands the native player can carry out alone are applied directly;
`next`, `previous`, `repeat`, `shuffle` and `playTrack` are handed to the
desktop's window, because only the window knows the queue.

For `fetchAudio`, an `audioReady` control frame is immediately followed on the
same receive stream by exactly `track.size` raw bytes and then stream finish.
The desktop MUST serve only an indexed supported-audio path contained by the
Expand All @@ -762,6 +792,12 @@ button. A report is kind `1984`:
}
```

`x` always carries the SHA-256 of the reported content, as NIP-56 defines it.
A report about an album cover names the cover event with `e` and carries the
album key in a `napstr-cover` tag; it MUST NOT put the album key in `x`. See
[NIP-NAPSTR-COVER.md](NIP-NAPSTR-COVER.md) for the cover report shape and for
why the key is repeated there.

Supported report types are `illegal`, `malware`, `spam`, `nudity`, `profanity`,
`impersonation`, and `other`.

Expand Down
63 changes: 63 additions & 0 deletions android/native/android/BackBridge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package net.napstr.nostrfy

import android.webkit.JavascriptInterface
import android.webkit.WebView
import java.lang.ref.WeakReference

/**
* Lets the page own the hardware back button.
*
* Kotlin cannot ask the page whether it has anything for back to close, so the
* webview pushes that flag on every transition and after every press it handles.
* While it is set, a back press is handed to the page and consumed; otherwise the
* press goes back to the system, so back still leaves the app when the page has
* nothing open.
*/
class BackBridge {
@JavascriptInterface
fun setBackAvailable(available: Boolean) {
backAvailable = available
}

/** The older name for the same flag, for a page that predates this one. */
@JavascriptInterface
fun setDrawerOpen(open: Boolean) {
backAvailable = open
}

companion object {
private const val BACK_EVENT = "napstrfy-back"
// Written from the webview's JS bridge thread and read from the UI thread.
@Volatile private var backAvailable = false
private var webView = WeakReference<WebView>(null)

fun attach(next: WebView) {
webView = WeakReference(next)
}

fun detach() {
backAvailable = false
webView.clear()
}

/**
* True when the press was handed to the page rather than the system.
*
* Clearing the flag here is what makes one press one answer: the page
* publishes again as soon as it has moved, which is why a page that still
* has somewhere to go - the search tab under the liked page - is not left
* with a flag that says otherwise and then goes unheard.
*/
fun consumeBack(): Boolean {
if (!backAvailable) return false
backAvailable = false
webView.get()?.post {
webView.get()?.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$BACK_EVENT'))",
null
)
}
return true
}
}
}
12 changes: 12 additions & 0 deletions android/native/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.WindowManager
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
Expand All @@ -19,6 +20,14 @@ class MainActivity : TauriActivity() {
super.onCreate(savedInstanceState)
// Keep Napstrfy visible until the user locks the phone or leaves the app.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// The page gets first refusal on back, then the press continues to the
// system so back still leaves the app when nothing is open.
onBackPressedDispatcher.addCallback(this) {
if (BackBridge.consumeBack()) return@addCallback
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}

override fun onWebViewCreate(webView: WebView) {
Expand All @@ -27,7 +36,9 @@ class MainActivity : TauriActivity() {
// token-protected loopback server allowed by the CSP and network policy.
webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
MediaControlBridge.attach(webView)
BackBridge.attach(webView)
webView.addJavascriptInterface(MediaControlBridge(this), "NapstrfyMedia")
webView.addJavascriptInterface(BackBridge(), "NapstrfyBack")
}

fun ensureMediaNotificationPermission() {
Expand All @@ -48,6 +59,7 @@ class MainActivity : TauriActivity() {

override fun onDestroy() {
MediaControlBridge.detach()
BackBridge.detach()
super.onDestroy()
}

Expand Down
23 changes: 21 additions & 2 deletions android/native/android/MediaControlBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,26 @@ class MediaControlBridge(private val activity: MainActivity) {
action = MediaNotificationService.ACTION_UPDATE
putExtra(MediaNotificationService.EXTRA_TITLE, safeText(state.optString("title"), 300))
putExtra(MediaNotificationService.EXTRA_ARTIST, safeText(state.optString("artist"), 300))
putExtra(MediaNotificationService.EXTRA_ARTWORK, safeText(state.optString("artwork"), 512))
putExtra(MediaNotificationService.EXTRA_PLAYING, state.optBoolean("playing"))
putExtra(MediaNotificationService.EXTRA_POSITION, state.optDouble("position").coerceIn(0.0, MAX_SECONDS).toLong() * 1000L)
putExtra(MediaNotificationService.EXTRA_DURATION, state.optDouble("duration").coerceIn(0.0, MAX_SECONDS).toLong() * 1000L)
putExtra(MediaNotificationService.EXTRA_CAN_PREVIOUS, state.optBoolean("canPrevious"))
putExtra(MediaNotificationService.EXTRA_CAN_NEXT, state.optBoolean("canNext"))
putExtra(MediaNotificationService.EXTRA_CAN_SEEK, state.optBoolean("canSeek"))
putExtra(MediaNotificationService.EXTRA_LIKED, state.optBoolean("liked"))
putExtra(MediaNotificationService.EXTRA_LOOPING, state.optBoolean("looping"))
// The state describes the computer's player rather than this phone's, and
// volume is a percentage of the computer's volume.
putExtra(MediaNotificationService.EXTRA_REMOTE, state.optBoolean("remote"))
putExtra(MediaNotificationService.EXTRA_VOLUME, state.optInt("volume", 0))
// Labels are translated by the webview, which owns the chosen language.
val labels = state.optJSONObject("labels")
for (key in listOf("previous", "rewind", "play", "pause", "forward", "next", "channel")) {
for (key in listOf(
"previous", "rewind", "play", "pause", "forward", "next", "channel",
"like", "unlike", "repeat", "repeatOff"
)
) {
putExtra("label_$key", safeText(labels?.optString(key).orEmpty(), 100))
}
}
Expand Down Expand Up @@ -57,7 +69,14 @@ class MediaControlBridge(private val activity: MainActivity) {
}

fun dispatch(action: String) {
if (action !in setOf("play", "pause", "previous", "next", "rewind", "forward") && !action.startsWith("seek:")) return
if (action !in setOf(
"play", "pause", "previous", "next", "rewind", "forward", "like", "repeat",
"volumeUp", "volumeDown"
) &&
!action.startsWith("seek:")
) {
return
}
webView.get()?.post {
val encoded = JSONObject.quote(action)
webView.get()?.evaluateJavascript(
Expand Down
Loading