android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found

The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
This commit is contained in:
KeelrLeek committed 2026-09-13 17:56:52 -05:00
1 parent bb828ff2dc
commit 6e39dc43f3
38 files changed
+2383 -88

No files matched your search

+101 -5
View File
@@ -172,6 +172,89 @@ namesake), the catalog's released records minus the shelf's by loose title,
dimmed cards that open the catalog record page, the totals remembered in
`library.gaps.v1` so the line says its number before the lookup.
## Playlists
The desktop's two playlist pages (`src/views/Playlists.svelte`,
`Playlist.svelte`), at phone scale, on the same commands
(`list_playlists`, `playlist_items`, `playlist_cover`, create / rename /
describe / pin / duplicate / delete, `playlist_add_items`,
`playlist_remove_items`).
- **The shelf** is Library → Playlists (`src/mobile/PlaylistShelf.svelte`):
the stat line, Pinned then All, the custom cover or the composed mosaic on
every card (fetched once per revision — `coverFor` in
`playlists.svelte.js`), a Setlist / N unresolved badge, New playlist (a
prompt sheet). A long press on a card is its sheet: Play, Shuffle, Pin,
Rename, Duplicate, Delete (a confirm sheet).
- **The page** (`screens/Playlist.svelte`) is the record page's shape: the
cover or mosaic, the kind and a Pinned chip, the description, the meta
line with its warnings (N unresolved, N missing), Play / Shuffle / More,
a filter, and the rows with the desktop's tags — `unresolved`,
`set audio`, `file missing`. Setlist cue times show only when every row
carries one. The bar's More is the page's sheet: Pin, Rename, Edit
description, Duplicate, Remove unresolved entries, Delete.
- **Playing** follows the desktop's `queueableRows`: Play queues the rows
that can play — present local files as themselves, stream rows as
path-less entries with their catalog identity, which the player resolves
on reach through `hooks.resolveEntry` (the same path a record's later
tracks take, so the three-ahead preload covers them). A tap on a weak row
with a name streams it one-off; a bare placeholder opens its sheet. The
playing row is found by `plItemId` on the queue entry. A set-recording
slice starts at its cue (`t.startSecs`) and the player moves on at the
next cue (`t.endSecs`); the scrubber still shows the whole recording.
- **A row's sheet** (`pltrack`): Play from here, Play next, Add to queue,
Save to library for a stream row, Go to artist, Remove from playlist.
A removal is undoable for seven seconds from the neutral toast
(`notice.svelte.js` — the desktop's undo pill); the stash pins the
playlist it came from.
- **Add to library / Download all tracks** on the page's sheet — the
desktop's bulk actions (`runBulk` in `playlists.svelte.js`, the rules
verbatim): the first files every already-streamed track whose cache is
byte-complete, the second finds a source for every stream row (the
remembered one, else the ladder) and hands it to the download pipeline;
the page shows the run's line with Stop, then its summary.
- **Add to playlist** is on every track and record sheet: a pick-a-playlist
sheet (New playlist at the top). A file on the phone becomes a local row;
a streamed or catalog track keeps its identity (`track_id`) and cover URL
and becomes a stream row.
- Every mutation re-lists through `soulseek:playlists-changed`, which the
desktop's commands already emit; the page re-reads its rows when the
playlist's revision moves.
- **Importing** (`importer.svelte.js`, `screens/Import.svelte`): the
shelf's Import opens the doors that need no file — a link (YouTube ·
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines,
a timestamped setlist, or track links), a YouTube DJ set's posted
tracklist. The backend stages the draft over plain HTTP (the same
commands as the desktop, no yt-dlp involved), the review screen shows
every row's verdict, the stream rows are verified against the catalog
while it is up (the desktop's pass, verbatim), and Create swaps the
review for the playlist it made (`nav.replace`).
- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`):
Share → SoulStream from any app. The plugin's `ShareActivity` (an
ACTION_SEND text/plain target, merged into the app's manifest from the
plugin module) keeps the text and brings the app's own task to the front
through its launcher intent; the WebView asks `pending_share` at boot and
whenever it comes back to the front, and a link opens the link door (the
first URL in the text — Spotify shares the link inside a sentence),
anything else the pasted-tracklist door. The phone walk sends the intent
itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the file and folder import doors (the files are on
the desktop), the listener door, the find-a-local-match repair panel, drag
reorder, the cover picker, export, Share on SoulStream.
## Settings that reach the config
`set_settings` takes **camelCase** arguments (Tauri's rule for command
parameters): `{ volumeNormalization, downloadDir, streamSourcePrefs,
downloadSourcePrefs }`. The phone's first You screen sent snake_case keys
and every save was silently ignored (found 2026-09-13 while adding the
Sources group — the walk now flips the switch and a chip and reads each
back from the config). You → Sources is the desktop's Source quality
section: per mode (streaming, downloads) the ceiling (no limit / lossy
only), the floor (any … lossless only) and the priority (best quality /
fastest transfer); the resolver re-reads them (`refreshSourcePrefs`).
## Background playback and the lock-screen card
The service is started with `startForegroundService`, so it promotes itself
@@ -342,12 +425,25 @@ waveform growing; backend events go over the mock's own bus, which
press → Search → Library and its tabs → a record → its sheets → the player's
three faces → the back gesture → an artist page → Community → a room → a
catalog search → a song through the resolver → the lane rising in the sheet →
You → the sleep timer, asserts what each step should show, screenshots every
step into `tests/mobile/out/`, and fails on any exception or console error.
Two toasts are expected there and filtered: without Tauri, `listen` and
`invoke` do not exist.
a catalog record, its Save and the Transfers burst → Gaps → the playlist
shelf and a page with every row kind (a local row, the stream row through
the resolver, the filter, a removal and its undo, unpin, rename, a new
playlist, Add to playlist from a record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, a share from
another app) → You (the normalization switch and a source-quality chip,
each read back from the config) → the sleep timer, asserts what each step
should show, screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The mock answers every command the phone
uses; one it does not know is named in the report (`[mock] no mock for X`)
rather than surfacing as a TypeError toast, console warnings carry their top
frames (the smoke bundle is built unminified for that), and every probe
carries the history mirror — the entry's depth against the layers the app
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
page after a confirmed delete was found).
The phone walk is the same driver on the real thing: a debug build's WebView
The phone walk is the same driver on the real thing (plus a step kind of
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
key): a debug build's WebView
exposes DevTools on `localabstract:webview_devtools_remote_<pid>`, `adb
forward` puts it on a local port, and the steps run against the real library
— real playback, real covers, the colour following the record — with the
Generated
+11
View File
@@ -195,6 +195,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-media-session",
"tauri-plugin-opener",
"tauri-plugin-share",
"tauri-plugin-storage",
"tauri-plugin-updater",
"tempfile",
@@ -5842,6 +5843,16 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-share"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-storage"
version = "0.1.0"
+1
View File
@@ -14,6 +14,7 @@ members = [
"crates/persona-state",
"crates/tauri-plugin-media-session",
"crates/tauri-plugin-storage",
"crates/tauri-plugin-share",
]
[workspace.package]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-share"
version = "0.1.0"
description = "SoulStream on the phone's share sheet: a link or a tracklist sent from another app lands in the playlist import"
edition = "2021"
rust-version = "1.77.2"
# required by tauri-plugin's build script; must equal the crate name
links = "tauri-plugin-share"
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,26 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.share"
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- SoulStream on the share sheet: plain text — a playlist link, a
pasted tracklist — from any app. The activity keeps the text
for the WebView (pending_share), brings the app to the front and
is gone; it never shows a screen of its own. -->
<activity
android:name="com.soulstream.share.ShareActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,27 @@
package com.soulstream.share
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/**
* The share sheet's target. Takes the text (Spotify sends the link inside a
* sentence; the WebView finds the URL in it), keeps it for the plugin,
* brings the app's own task to the front through its launcher intent — the
* running one if there is one, so the page and its player are untouched —
* and finishes without ever drawing.
*/
class ShareActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val text = intent?.getStringExtra(Intent.EXTRA_TEXT)
?: intent?.getStringExtra(Intent.EXTRA_SUBJECT)
if (!text.isNullOrBlank()) SharePlugin.pending = text
val launch = packageManager.getLaunchIntentForPackage(packageName)
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launch)
}
finish()
}
}
@@ -0,0 +1,32 @@
package com.soulstream.share
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/**
* What another app shared to SoulStream, held until the WebView asks.
* ShareActivity puts it here (the two live in one process, so a static
* holds across the hand-off, a cold start included); `pendingShare` gives
* it out once — the second ask answers nothing.
*/
@TauriPlugin
class SharePlugin(private val activity: Activity) : Plugin(activity) {
companion object {
@Volatile
var pending: String? = null
}
@Command
fun pendingShare(invoke: Invoke) {
val out = JSObject()
val text = pending
pending = null
if (text != null) out.put("text", text)
invoke.resolve(out)
}
}
+13
View File
@@ -0,0 +1,13 @@
// The one command, as the Rust function is named; the ACL slugs it
// (`allow-pending-share`) into permissions/autogenerated.
const COMMANDS: &[&str] = &["pending_share"];
fn main() {
// Registers the command permission and, on Android, hands the Gradle
// module under android/ to tauri-build (only with
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md). The module carries
// the ShareActivity and its intent filter, merged into the app's manifest.
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pending-share"
description = "Enables the pending_share command without any pre-configured scope."
commands.allow = ["pending_share"]
[[permission]]
identifier = "deny-pending-share"
description = "Denies the pending_share command without any pre-configured scope."
commands.deny = ["pending_share"]
@@ -0,0 +1,44 @@
## Default Permission
The share sheet: what another app sent to SoulStream (a playlist link, a
pasted tracklist), read once by the import.
#### This default permission set includes the following:
- `allow-pending-share`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`share:allow-pending-share`
</td>
<td>
Enables the pending_share command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`share:deny-pending-share`
</td>
<td>
Denies the pending_share command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,8 @@
"$schema" = "schemas/schema.json"
[default]
description = """
The share sheet: what another app sent to SoulStream (a playlist link, a
pasted tracklist), read once by the import.
"""
permissions = ["allow-pending-share"]
@@ -0,0 +1,318 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the pending_share command without any pre-configured scope.",
"type": "string",
"const": "allow-pending-share",
"markdownDescription": "Enables the pending_share command without any pre-configured scope."
},
{
"description": "Denies the pending_share command without any pre-configured scope.",
"type": "string",
"const": "deny-pending-share",
"markdownDescription": "Denies the pending_share command without any pre-configured scope."
},
{
"description": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`",
"type": "string",
"const": "default",
"markdownDescription": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`"
}
]
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! The share sheet, on the phone.
//!
//! Another app's Share → SoulStream (a Spotify playlist, a YouTube set, a
//! pasted tracklist) starts the plugin's `ShareActivity`, which keeps the
//! text and brings the app to the front. The WebView asks for it with
//! `pending_share` → `{ text }` — at boot, and whenever the app comes back
//! to the front — and hands it to the playlist import. Read once: the
//! second ask answers nothing. On the desktop the plugin is registered so
//! the permission set resolves, and the command answers nothing.
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Shared {
pub text: Option<String>,
}
#[cfg(mobile)]
pub struct Share<R: Runtime>(tauri::plugin::PluginHandle<R>);
#[tauri::command]
fn pending_share<R: Runtime>(app: tauri::AppHandle<R>) -> Result<Shared, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let share = app.state::<Share<R>>();
share
.0
.run_mobile_plugin::<Shared>("pendingShare", ())
.map_err(|e| e.to_string())
}
#[cfg(not(mobile))]
{
let _ = app;
Ok(Shared::default())
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("share")
.invoke_handler(tauri::generate_handler![pending_share])
.setup(|app, api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = api.register_android_plugin("com.soulstream.share", "SharePlugin")?;
app.manage(Share(handle));
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, api);
}
Ok(())
})
.build()
}
+1
View File
@@ -59,6 +59,7 @@ audio-formats = { path = "../crates/audio-formats" }
# platform; on the desktop its commands answer "not here".
tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" }
tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" }
tauri-plugin-share = { path = "../crates/tauri-plugin-share" }
semver = "1"
tauri-plugin-opener = "2.5.5"
+2 -1
View File
@@ -22,6 +22,7 @@
"dialog:allow-open",
"media-session:default",
"opener:default",
"storage:default"
"storage:default",
"share:default"
]
}
+2
View File
@@ -83,6 +83,8 @@ pub fn run() {
app.handle().plugin(tauri_plugin_opener::init())?;
// The library folder on the phone: the picker and all-files access.
app.handle().plugin(tauri_plugin_storage::init())?;
// The share sheet on the phone: a link sent from another app lands in the import.
app.handle().plugin(tauri_plugin_share::init())?;
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Must run before anything touches the app-data dir.
+3
View File
@@ -497,6 +497,9 @@ export const rescanLibrary = () => invoke("rescan_library");
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access");
// The phone's share plugin (crates/tauri-plugin-share): what another app
// shared to SoulStream, { text } — read once.
export const pendingShare = () => invoke("plugin:share|pending_share");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
// second batch's indices continuing, since index 0 means "track 1".
+253 -7
View File
@@ -84,6 +84,199 @@ function tracksFor(albumId) {
}));
}
/* ── playlists: the desktop's shapes (library::Playlist rows with the
command layer's duration_secs / unresolved_count, PlaylistItemPayload
rows with `missing`). One pinned playlist holding every row kind, a
setlist with coherent cue times, and an empty one. Mutations bump the
revision and emit soulseek:playlists-changed, as the real commands do. */
let plSeq = 3;
let plItemSeq = 100;
const PL_T0 = 1_725_000_000;
const PL = [
{ id: 1, name: "Late night drive", track_count: 0, description: "Slow, wide, mostly synths.", kind: "manual", pinned: true, cover_path: null, created_at: PL_T0, updated_at: PL_T0, revision: 1, duration_secs: 0, unresolved_count: 0 },
{ id: 2, name: "Boiler Room 2019", track_count: 0, description: null, kind: "setlist", pinned: false, cover_path: null, created_at: PL_T0 + 10, updated_at: PL_T0 + 10, revision: 1, duration_secs: 0, unresolved_count: 0 },
{ id: 3, name: "Empty one", track_count: 0, description: null, kind: "manual", pinned: false, cover_path: null, created_at: PL_T0 + 20, updated_at: PL_T0 + 20, revision: 1, duration_secs: 0, unresolved_count: 0 },
];
function plLocalRow(albumId, k, extra = {}) {
const t = tracksFor(albumId)[k];
const a = albums.find((x) => x.id === albumId);
return { title: t.title, artist: a.artist, album: a.title, duration_secs: t.duration_secs, path: t.path, art_path: a.art_path, kind: "local", source_line: null, start_ms: null, note: null, track_id: null, art_url: null, missing: false, ...extra };
}
const PL_ITEMS = {
1: [
plLocalRow(1, 0), plLocalRow(2, 1), plLocalRow(5, 2, { missing: true }), plLocalRow(9, 3),
{ title: "One More Time", artist: "Daft Punk", album: "Discovery", duration_secs: 200, path: null, art_path: null, kind: "stream", source_line: "Daft Punk - One More Time", start_ms: null, note: null, track_id: "3135550", art_url: svgArt(200, "D"), missing: false },
{ title: "ID", artist: "ID", album: null, duration_secs: null, path: null, art_path: null, kind: "unresolved", source_line: "07 ID - ID", start_ms: null, note: null, track_id: null, art_url: null, missing: false },
],
2: [plLocalRow(3, 0, { start_ms: 0 }), plLocalRow(3, 1, { start_ms: 245_000 }), plLocalRow(3, 2, { start_ms: 1_130_000 })],
3: [],
};
for (const id of Object.keys(PL_ITEMS)) PL_ITEMS[id].forEach((r, i) => { r.id = ++plItemSeq; r.position = i; });
function plSync(p) {
const rows = PL_ITEMS[p.id] || [];
p.track_count = rows.length;
p.duration_secs = rows.reduce((n, r) => n + (r.duration_secs || 0), 0);
p.unresolved_count = rows.filter((r) => r.kind === "unresolved").length;
}
PL.forEach(plSync);
function plTouch(p) {
if (p) { p.revision++; p.updated_at = Math.floor(Date.now() / 1000); plSync(p); }
setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30);
}
const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId));
function mockPlaylists(cmd, args) {
if (cmd === "list_playlists") return Promise.resolve(PL.map((p) => ({ ...p })));
if (cmd === "playlist_items") return new Promise((r) => setTimeout(() => r((PL_ITEMS[Number(args?.id)] || []).map((x) => ({ ...x }))), 80));
if (cmd === "playlist_cover") { const p = plOf(args); return Promise.resolve(p && (p.id === 1 || p.cover_path) ? svgArt(p.id === 1 ? 330 : 280, p.id === 1 ? "L" : "M") : null); }
if (cmd === "create_playlist") {
const p = { id: ++plSeq, name: args?.name || "New Playlist", track_count: 0, description: null, kind: args?.kind || "manual", pinned: false, cover_path: null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 };
PL.push(p);
PL_ITEMS[p.id] = [];
plTouch(p);
return Promise.resolve(p.id);
}
if (cmd === "rename_playlist") { const p = plOf(args); if (p) p.name = args.name; plTouch(p); return Promise.resolve(); }
if (cmd === "set_playlist_description") { const p = plOf(args); if (p) p.description = args.description || null; plTouch(p); return Promise.resolve(); }
if (cmd === "set_playlist_pinned") { const p = plOf(args); if (p) p.pinned = !!args.pinned; plTouch(p); return Promise.resolve(); }
if (cmd === "duplicate_playlist") {
const p = plOf(args);
if (!p) return Promise.reject(new Error("duplicate_playlist: no such playlist"));
const c = { ...p, id: ++plSeq, name: args?.name || p.name + " copy", pinned: false, revision: 0 };
PL.push(c);
PL_ITEMS[c.id] = (PL_ITEMS[p.id] || []).map((x, i) => ({ ...x, id: ++plItemSeq, position: i }));
plTouch(c);
return Promise.resolve(c.id);
}
if (cmd === "delete_playlist") {
const i = PL.findIndex((p) => p.id === Number(args?.id));
if (i >= 0) { delete PL_ITEMS[PL[i].id]; PL.splice(i, 1); }
plTouch(null);
return Promise.resolve();
}
if (cmd === "playlist_remove_items") {
const p = plOf(args);
const ids = new Set((args?.itemIds || []).map(Number));
if (p) { PL_ITEMS[p.id] = (PL_ITEMS[p.id] || []).filter((x) => !ids.has(x.id)); PL_ITEMS[p.id].forEach((x, i) => { x.position = i; }); }
plTouch(p);
return Promise.resolve();
}
if (cmd === "playlist_add_items") {
const p = plOf(args);
if (!p) return Promise.reject(new Error("playlist_add_items: no such playlist"));
const rows = (args?.items || []).map((it) => ({
title: it.title, artist: it.artist || "", album: it.album ?? null, duration_secs: it.duration_secs ?? null,
path: it.path ?? null, art_path: it.art_path ?? null,
kind: it.path ? "local" : (it.title && it.artist ? "stream" : "unresolved"),
source_line: it.source_line ?? null, start_ms: it.start_ms ?? null, note: it.note ?? null,
track_id: it.track_id ?? null, art_url: it.art_url ?? null, missing: false, id: ++plItemSeq,
}));
const list = PL_ITEMS[p.id];
const at = args?.at == null ? list.length : Math.min(Number(args.at), list.length);
list.splice(at, 0, ...rows);
list.forEach((x, i) => { x.position = i; });
plTouch(p);
return Promise.resolve(rows.map((r) => r.id));
}
return undefined;
}
/* ── the import doors: a pasted tracklist, a link, a YouTube set — a draft
in the backend's shape (ImportDraft / DraftEntry, resolved against the
mock library), then commit makes the playlist ── */
function impEntry(ordinal, artist, title, raw, start_ms = null) {
const own = albums.find((a) => a.artist.toLowerCase() === artist.toLowerCase());
const tr = own ? tracksFor(own.id).find((t) => t.title.toLowerCase() === title.toLowerCase()) : null;
const weak = !artist || !title || title.toLowerCase() === "id";
return {
ordinal, title, artist, album: tr ? own.title : null,
duration_secs: tr ? tr.duration_secs : (weak ? null : 200 + ((ordinal * 37) % 180)),
start_ms, raw,
resolution: tr ? "local" : weak ? "unresolved" : "stream",
matched_by: tr ? "tags" : null,
path: tr ? tr.path : null, art_path: tr ? own.art_path : null,
track_id: null, art_url: null, isrc: null,
};
}
function impParse(text) {
const out = [];
for (const raw of String(text || "").split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const m = /^(?:(\d+):(\d\d)(?::(\d\d))?\s+)?(.*?)\s+[-–]\s+(.*)$/.exec(line);
const start_ms = m && m[1] != null ? ((m[3] != null ? (+m[1] * 3600 + +m[2] * 60 + +m[3]) : (+m[1] * 60 + +m[2])) * 1000) : null;
out.push(impEntry(out.length, m ? m[4].trim() : "", m ? m[5].trim() : line, raw, start_ms));
}
return out;
}
function impDraft(entries, source_name, format, name = null, extra = {}) {
return {
source_name, format, name, description: null,
kind: entries.some((e) => e.start_ms != null) ? "setlist" : "manual",
entries, warnings: [],
resolved_count: entries.filter((e) => e.resolution === "local").length,
stream_count: entries.filter((e) => e.resolution === "stream").length,
unresolved_count: entries.filter((e) => e.resolution === "unresolved").length,
cover_url: null,
...extra,
};
}
function mockImport(cmd, args) {
if (cmd === "playlist_import_text") {
const entries = impParse(args?.text);
if (!entries.length) return Promise.reject(new Error("Nothing in that text reads as a track."));
return new Promise((r) => setTimeout(() => r(impDraft(entries, "Pasted tracklist", "text")), 400));
}
if (cmd === "playlist_import_link" || cmd === "playlist_import_youtube") {
const url = String(args?.url || "");
if (!/^https?:\/\//.test(url)) return Promise.reject(new Error("That is not a link."));
const set = cmd === "playlist_import_youtube";
const format = set ? "youtube" : /spotify/.test(url) ? "spotify" : /apple/.test(url) ? "apple-music" : /tidal/.test(url) ? "tidal" : /deezer/.test(url) ? "deezer" : /youtu/.test(url) ? "youtube-playlist" : null;
if (!format) return Promise.reject(new Error("That link is not a playlist SoulStream can read."));
const lines = set
? ["00:00 Daft Punk - One More Time", "05:20 Daft Punk - Aerodynamic", "09:00 ID - ID", "12:10 Aphex Twin - Track 1"]
: ["Daft Punk - One More Time", "Daft Punk - Aerodynamic", "Daft Punk - Digital Love", "Aphex Twin - Track 1", "ID - ID"];
const entries = impParse(lines.join("\n"));
return new Promise((r) => {
let done = 0;
const total = entries.length;
const tick = setInterval(() => {
done++;
mockEmit("playlist:link-progress", { done, total, waiting: false });
if (done >= total) {
clearInterval(tick);
setTimeout(() => r(impDraft(entries,
set ? "Mock Channel — Boiler Room 2019" : "Mock Curator — Late Night Mix", format,
set ? "Boiler Room 2019 (live)" : "Late Night Mix",
{ cover_url: svgArt(280, "M"), warnings: set ? ["Tracklist from the video's chapters."] : [] })), 200);
}
}, 180);
});
}
if (cmd === "playlist_import_commit") {
const name = String(args?.name || "").trim();
if (!name) return Promise.reject(new Error("The playlist needs a name"));
const p = { id: ++plSeq, name, track_count: 0, description: args?.description ?? null, kind: args?.kind === "setlist" ? "setlist" : "manual", pinned: false, cover_path: args?.coverUrl ? "mock://cover" : null, created_at: Math.floor(Date.now() / 1000), updated_at: 0, revision: 0, duration_secs: 0, unresolved_count: 0 };
PL.push(p);
PL_ITEMS[p.id] = (args?.entries || []).map((e, i) => ({
id: ++plItemSeq, position: i, title: e.title, artist: e.artist, album: e.album ?? null, duration_secs: e.duration_secs ?? null,
path: e.path ?? null, art_path: e.art_path ?? null,
kind: e.path ? "local" : e.resolution === "unresolved" ? "unresolved" : "stream",
source_line: e.raw ?? null, start_ms: e.start_ms ?? null, note: null,
track_id: e.track_id != null ? String(e.track_id) : null, art_url: e.art_url ?? null, missing: false,
}));
plTouch(p);
return Promise.resolve(p.id);
}
if (cmd === "catalog_search_tracks") {
const words = String(args?.query || "").toLowerCase().replace(/\b(artist|track|album):/g, " ").replace(/["']/g, " ").split(/\s+/).filter(Boolean);
const hits = CAT_TRACKS.filter((t) => { const hay = (t.artist + " " + t.title + " " + t.album).toLowerCase(); return words.every((w) => hay.includes(w)); });
return new Promise((r) => setTimeout(() => r(hits.slice(0, args?.limit || 10)), 90));
}
if (cmd === "catalog_track_by_isrc") return Promise.resolve(null);
if (cmd === "catalog_artist_track_candidates") return Promise.resolve([]);
return undefined;
}
const enabled = (() => {
try {
return localStorage.getItem("mockLibrary") === "1";
@@ -95,6 +288,7 @@ const enabled = (() => {
/** Returns a Promise for mocked commands, or undefined to pass through. */
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
if (cmd === "recent_albums") return Promise.resolve([]);
@@ -105,7 +299,7 @@ export function mockInvoke(cmd, args) {
const num = m ? +m[1] : 0;
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
}
if (cmd === "list_playlists") return Promise.resolve([]);
{ const pl = mockPlaylists(cmd, args) ?? mockImport(cmd, args); if (pl) return pl; }
// The track index, as the library crate shapes it (TrackIndexRow) — the
// phone's Tracks tab and its library search read these.
if (cmd === "library_track_index" || cmd === "library_track_search") {
@@ -120,7 +314,9 @@ export function mockInvoke(cmd, args) {
? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40)
: []);
}
return mockCatalog(cmd, args) ?? mockNet(cmd, args);
const r = mockCatalog(cmd, args) ?? mockNet(cmd, args);
if (r === undefined) console.warn("[mock] no mock for " + cmd + " — the real IPC is not here");
return r;
}
/* ── the fake network ───────────────────────────────────────────────────
@@ -270,22 +466,40 @@ function mockCatalog(cmd, args) {
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
if (cmd === "preload_stream") {
const path = args?.path;
cacheSizes.set(path, args?.size || 0);
if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); }
return Promise.resolve();
}
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
if (cmd === "get_settings") return Promise.resolve({ download_dir: "C:\\mock\\Library" });
if (cmd === "set_settings") return Promise.resolve();
if (cmd === "get_settings") return Promise.resolve({ ...mockSettings, stream_source_prefs: { ...mockSettings.stream_source_prefs }, download_source_prefs: { ...mockSettings.download_source_prefs } });
if (cmd === "set_settings") {
/* the command's arguments are camelCase; the config's fields are snake_case */
for (const [k, v] of Object.entries(args || {})) mockSettings[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v;
return Promise.resolve();
}
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
if (t && window.__ssMock) window.__ssMock.share = null;
return Promise.resolve({ text: t });
}
return undefined;
}
let token = 0;
let downloadSeq = 0;
const cacheReady = new Set(); // paths a preload has fully fetched
const mockSettings = {
download_dir: "C:\\mock\\Library",
volume_normalization: true,
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
download_source_prefs: { max: "any", min: "any", priority: "quality" },
};
const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched
const cacheSizes = new Map(); // path → the file's size, for stream_cache_len
const preloading = new Set();
let xfer = null; // the one fake transfer: { received, total, timer }
let posTimer = null; // the fake engine's position ticks
@@ -328,17 +542,38 @@ function mockNet(cmd, args) {
return Promise.resolve(t);
}
if (cmd === "stop_search") return Promise.resolve();
if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 });
if (cmd === "preload_local") return Promise.resolve();
if (cmd === "waveform") {
const n = args?.buckets || 900;
return new Promise((r) => setTimeout(() => r(Array.from({ length: n }, (_, i) => 0.2 + 0.75 * Math.abs(Math.sin(i * 0.23) * Math.cos(i * 0.017)))), 120));
}
if (cmd === "lyrics_get") return Promise.resolve(null);
if (cmd === "play_local") {
/* a file: the engine reports playing after a beat, then the position
from where it was asked to start */
if (xfer) { clearInterval(xfer.timer); xfer = null; }
clearInterval(posTimer);
const duration = args?.duration || 240;
let secs = Number(args?.startSecs) || 0;
later(300, () => {
mockEmit("soulseek:player", { kind: "playing" });
posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500);
});
return Promise.resolve();
}
if (cmd === "play_stream") {
if (xfer) clearInterval(xfer.timer);
clearInterval(posTimer);
const total = args?.size || 30e6;
const duration = args?.duration || 240;
xfer = { received: 0, total, timer: null, path: args?.path };
cacheSizes.set(args?.path, total);
xfer.timer = setInterval(() => {
xfer.received = Math.min(total, xfer.received + total / 30);
const done = xfer.received >= total;
mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null });
if (done) clearInterval(xfer.timer);
if (done) { clearInterval(xfer.timer); cacheReady.add(xfer.path); }
}, 400);
let secs = 0;
later(1200, () => {
@@ -353,7 +588,18 @@ function mockNet(cmd, args) {
const peaks = Array.from({ length: n }, (_, i) => 0.25 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.011)));
return Promise.resolve({ coverage, peaks });
}
if (cmd === "stream_cache_len") return Promise.resolve(xfer ? Math.round(xfer.received) : 0);
if (cmd === "stream_cache_len") return Promise.resolve(cacheReady.has(args?.path) ? (cacheSizes.get(args.path) || 0) : (xfer && xfer.path === args?.path ? Math.round(xfer.received) : 0));
if (cmd === "save_to_library") {
/* a byte-complete cache files at once; anything else goes to the download pipeline */
let organized = 0, queued = 0;
for (const f of args?.files || []) {
if (cacheReady.has(f.path)) { organized++; continue; }
queued++;
mockInvoke("download_file", { username: f.username, path: f.path, size: f.size });
}
if (organized) later(30, () => mockEmit("soulseek:library-changed", {}));
return Promise.resolve([organized, queued]);
}
if (cmd === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null);
if (cmd === "record_play") return Promise.resolve(1);
if (cmd === "add_play_seconds") return Promise.resolve();
+199 -1
View File
@@ -9,8 +9,10 @@
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
@@ -26,6 +28,7 @@
const t = top();
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
return "home-more";
});
@@ -51,6 +54,94 @@
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
/* ── playlists: the shelf card's sheet, the page's overflow (the bar's
More resolves to it), a row's sheet, the pick-a-playlist sheet, and
the prompt / confirm sheets they open ── */
const plArg = $derived.by(() => {
if (kind === "playlist") {
const id = shown?.arg?.playlist?.id;
if (id == null) return null;
return { playlist: lib.playlists.find((p) => p.id === id) || shown.arg.playlist, items: null };
}
if (kind !== "playlist-more") return null;
const id = shown?.arg?.playlist?.id ?? top().c.id;
const p = lib.playlists.find((x) => x.id === id);
if (!p) return null;
return { playlist: p, items: pls.current?.id === p.id ? pls.current.items : (shown?.arg?.items || []) };
});
const oops = (e) => { player.error = String(e); };
async function playPl(p, shuffle = false) {
try {
const items = (await api.playlistItems(p.id)) || [];
if (!(shuffle ? shufflePlay(p, items) : playFrom(p, items))) player.error = "Nothing in “" + p.name + "” can play yet.";
} catch (e) { oops(e); }
}
function confirmDelete(p) {
openHalf("confirm", {
title: "Delete “" + p.name + "”?",
body: (p.track_count ? "Its " + p.track_count + " row" + (p.track_count === 1 ? "" : "s") + " go with it. " : "") + "The files stay in your library.",
ok: "Delete", danger: true,
onconfirm: async () => {
try {
if (top().n === "playlist" && top().c.id === p.id) back();
await deletePlaylist(p);
} catch (e) { oops(e); }
},
});
}
/* the prompts take the playlist as a VALUE: a {@const} of the sheet's
block is gone by the time the prompt submits (the sheet switched
kinds), and reading it then is a stale derived */
function renamePrompt(p) {
openHalf("prompt", { title: "Rename playlist", value: p.name, placeholder: "Name", ok: "Rename", onsubmit: (v) => renamePlaylist(p, v).catch(oops) });
}
function describePrompt(p) {
openHalf("prompt", { title: "Description", value: p.description || "", placeholder: "What this playlist is", ok: "Save", multiline: true, allowEmpty: true, onsubmit: (v) => describePlaylist(p, v).catch(oops) });
}
function newPlaylistThenAdd(entries) {
openHalf("prompt", {
title: "New playlist", placeholder: "Name", value: "New Playlist " + (lib.playlists.length + 1), ok: "Create",
onsubmit: async (name) => {
try {
const id = await createPlaylist(name);
await addEntries(lib.playlists.find((x) => x.id === id) || { id, name }, entries);
} catch (e) { oops(e); }
},
});
}
/* the import doors: a prompt for the link or the text, then the review
screen while the backend reads and resolves */
function importPrompt(door) {
const d = DOORS.find((x) => x[0] === door);
openHalf("prompt", {
title: d[2].replace(/…$/, ""),
ok: "Import",
multiline: door === "text",
placeholder: door === "text" ? "00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n…or pasted track links"
: door === "youtube" ? "https://www.youtube.com/watch?v=…"
: "https://open.spotify.com/playlist/…",
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* the prompt's field, seeded when the prompt opens */
let promptVal = $state("");
$effect(() => {
const h = nav.half;
if (h?.kind === "prompt") promptVal = h.arg?.value ?? "";
});
function submitPrompt() {
const a = shown?.arg;
if (!a) return;
const v = promptVal.trim();
if (!v && !a.allowEmpty) return;
closeHalf();
a.onsubmit?.(v);
}
function focusLater(node) {
const t = setTimeout(() => node.focus(), 280);
return { destroy: () => clearTimeout(t) };
}
/* a catalog track (no file on this phone): the resolver plays it, Save
fetches it — the desktop's row buttons, on the sheet */
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
@@ -100,6 +191,7 @@
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.catalogId != null}
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
@@ -110,6 +202,7 @@
{:else}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.id != null && !onThisAlbum(album.id)}
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
@@ -137,6 +230,9 @@
<div class="sheet-rows">
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
{@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))}
{#if tracks.length}
{@render row("playlist", "Add to playlist", tracks.length + " tracks", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t, album })) }))}
{/if}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
</div>
@@ -145,6 +241,108 @@
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
</div>
{:else if (kind === "playlist" || kind === "playlist-more") && plArg}
{@const p = plArg.playlist}
{@const rows = plArg.items || []}
{@const unres = rows.filter((i) => i.kind === "unresolved")}
<div class="sheet-head">
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={22} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if kind === "playlist"}
{@render row("play", "Play", "", act(() => playPl(p)))}
{@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))}
<div class="sheet-hair"></div>
{/if}
{@render row("pin", p.pinned ? "Unpin" : "Pin", p.pinned ? "" : "keeps it at the top of the shelf", act(() => togglePin(p).catch(oops)))}
{@render row("pencil", "Rename", "", () => renamePrompt(p))}
{#if kind === "playlist-more"}
{@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))}
{/if}
{@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
{#if kind === "playlist-more" && unres.length}
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
{/if}
{#if kind === "playlist-more" && bulkable(rows) && !bulk.active}
<div class="sheet-hair"></div>
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
{@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))}
{/if}
<div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
</div>
{:else if kind === "pltrack" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const rows = shown.arg.items || []}
{@const item = shown.arg.item}
<div class="sheet-head">
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if queueable(item)}
{@render row("play", "Play from here", "the rest of the playlist follows", act(() => playFrom(p, rows, item)))}
{@render row("skip-forward", "Play next", "", act(() => playNext([entryOf(item, p)])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([entryOf(item, p)])))}
{#if !item.path}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack({ title: item.title, artist: item.artist }, null); })}
{/if}
{:else if item.kind === "unresolved"}
<div class="sec-note" style="padding: 2px var(--pad) 10px">This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.</div>
{:else if item.missing}
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
{/if}
{#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if}
<div class="sheet-hair"></div>
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
</div>
{:else if kind === "pickPlaylist" && shown?.arg?.entries}
{@const entries = shown.arg.entries}
<div class="sheet-title">Add to playlist <span class="t-sub">· {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}</span></div>
<div class="sheet-rows">
{@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))}
<div class="sheet-hair"></div>
{#each lib.playlists as p (p.id)}
<button class="row" onclick={act(() => addEntries(p, entries).catch(oops))}>
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={18} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.track_count} track{p.track_count === 1 ? "" : "s"}{p.pinned ? " · pinned" : ""}</span></span>
</button>
{/each}
{#if !lib.playlists.length}<div class="sec-note" style="padding-bottom: 12px">No playlists yet — make one above.</div>{/if}
</div>
{:else if kind === "prompt" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
<div class="prompt">
{#if a.multiline}
<textarea class="field area" bind:value={promptVal} placeholder={a.placeholder || ""} rows="3" use:focusLater></textarea>
{:else}
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
{/if}
<div class="prompt-acts">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" disabled={!a.allowEmpty && !promptVal.trim()} onclick={submitPrompt}>{a.ok || "OK"}</button>
</div>
</div>
{:else if kind === "confirm" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
{#if a.body}<div class="sec-note" style="padding-bottom: 12px">{a.body}</div>{/if}
<div class="prompt-acts" style="padding: 0 var(--pad) 16px">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" class:danger={a.danger} onclick={() => { const f = a.onconfirm; closeHalf(); f?.(); }}>{a.ok || "OK"}</button>
</div>
{:else if kind === "import"}
<div class="sheet-title">Import a playlist</div>
<div class="sheet-rows">
{#each DOORS as [door, icon, t, m] (door)}
{@render row(icon, t, m, () => importPrompt(door))}
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
+18 -2
View File
@@ -22,6 +22,7 @@
import Artist from "./screens/Artist.svelte";
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
@@ -40,6 +41,10 @@
import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import { notice, clearNote } from "./notice.svelte.js";
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
import * as sources from "../lib/sources.js";
import { greeting, drag } from "./util.js";
@@ -62,6 +67,7 @@
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
@@ -79,7 +85,7 @@
}
case "playlist": {
const p = lib.playlists.find((x) => x.id === e.c.id);
return { title: p?.name ?? "Playlist", kicker: "Playlist", flush: true };
return { title: p?.name ?? "Playlist", kicker: p?.kind === "setlist" ? "Setlist" : "Playlist", flush: true };
}
default: return { title: e.n, kicker: "" };
}
@@ -174,9 +180,11 @@
watchLibrary();
bindTransfers();
bindHome();
bindImporter();
bindShare();
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, next, prev, toggle, seekSecs, playQueueAt, patch };
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, next, prev, toggle, seekSecs, playQueueAt, patch };
return () => { if (unlisten) unlisten(); };
});
</script>
@@ -214,6 +222,8 @@
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
{:else if entry.n === "playlist"}
<Playlist id={entry.c.id} />
{:else if entry.n === "import"}
<Import />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
@@ -279,4 +289,10 @@
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
{/if}
{#if notice.text}
<div class="toast notice" role="status">
<span class="nt">{notice.text}</span>
{#if notice.action}<button class="na" onclick={() => { const f = notice.action.fn; clearNote(); f?.(); }}>{notice.action.label}</button>{/if}
</div>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
<script>
/* Library → Playlists: the desktop's Playlists shelf — the stat line,
Pinned then All, the cover or the mosaic on every card, New playlist.
A long press on a card is its sheet (Pin, Rename, Duplicate, Delete);
the import doors stay on the desktop for now. */
import Icon from "../components/Icon.svelte";
import Release from "./Release.svelte";
import { lib } from "./lib.svelte.js";
import { push, openHalf } from "./nav.svelte.js";
import { player } from "./player.svelte.js";
import { coverFor, createPlaylist, shelfTotals } from "./playlists.svelte.js";
import { longDur } from "./util.js";
let { screenEl = null } = $props();
const pinned = $derived(lib.playlists.filter((p) => p.pinned));
const rest = $derived(lib.playlists.filter((p) => !p.pinned));
const totals = $derived(shelfTotals(lib.playlists));
let creating = false;
function newPlaylist() {
openHalf("prompt", {
title: "New playlist",
placeholder: "Name",
value: `New Playlist ${lib.playlists.length + 1}`,
ok: "Create",
onsubmit: async (name) => {
if (creating) return;
creating = true;
try {
const id = await createPlaylist(name);
push("playlist", { id }, screenEl);
} catch (e) {
player.error = String(e);
} finally { creating = false; }
},
});
}
const sub = (p) => `${p.track_count} track${p.track_count === 1 ? "" : "s"}${p.duration_secs ? " · " + longDur(p.duration_secs) : ""}`;
const badge = (p) => (p.kind === "setlist" ? "SETLIST" : p.unresolved_count > 0 ? p.unresolved_count + " UNRESOLVED" : null);
</script>
<div class="plstats">
{#if lib.playlists.length}
<span class="t-sub">{lib.playlists.length} playlist{lib.playlists.length === 1 ? "" : "s"} · {totals.tracks.toLocaleString()} track{totals.tracks === 1 ? "" : "s"}{totals.secs ? " · " + longDur(totals.secs) : ""}{pinned.length ? " · " + pinned.length + " pinned" : ""}</span>
{:else}
<span class="t-sub">No playlists yet</span>
{/if}
</div>
<div class="plbtns">
<button class="btn btn-ghost newpl" onclick={() => openHalf("import")}><Icon name="download" size={15} strokeWidth={3} />Import</button>
<button class="btn btn-ghost newpl" onclick={newPlaylist}><Icon name="plus" size={15} strokeWidth={3} />New playlist</button>
</div>
{#if !lib.playlists.length}
<div class="stub"><b>No playlists on this phone yet.</b>Make one here, or add a track to a new one from its sheet. Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
{/if}
{#snippet card(p)}
<Release title={p.name} sub={sub(p)} url={coverFor(p)} badge={badge(p)} mark={p.pinned ? "pin" : null} icon="playlist"
onclick={() => push("playlist", { id: p.id }, screenEl)} onmore={() => openHalf("playlist", { playlist: p })} />
{/snippet}
{#if pinned.length}
<div class="sec plsec pinned"><h2><Icon name="pin" size={12} strokeWidth={3} />Pinned</h2></div>
<div class="grid2 plgrid">
{#each pinned as p (p.id)}{@render card(p)}{/each}
</div>
{#if rest.length}<div class="sec plsec"><h2>All playlists</h2></div>{/if}
{/if}
{#if rest.length}
<div class="grid2 plgrid">
{#each rest as p (p.id)}{@render card(p)}{/each}
</div>
{/if}
+3 -2
View File
@@ -6,7 +6,7 @@
/* `path` is a library cover (through the thumbnail cache); `url` a
catalog cover, shown straight from the network */
let { title, sub = "", path = null, url = null, badge = null, w2 = false, onclick, onmore = null } = $props();
let { title, sub = "", path = null, url = null, badge = null, mark = null, icon = "disc", w2 = false, onclick, onmore = null } = $props();
const src = $derived(url || (path ? art[path] : ""));
function hold(e) {
if (!onmore) return;
@@ -18,7 +18,8 @@
<button class="release" class:w2 {onclick} oncontextmenu={hold} use:visible={path}>
<span class="cover-wrap">
{#if badge}<span class="ed-badge">{badge}</span>{/if}
{#if src}<img src={src} alt="" loading="lazy" />{:else}<span class="ph"><Icon name="disc" size={26} /></span>{/if}
{#if mark}<span class="mark"><Icon name={mark} size={11} strokeWidth={3} /></span>{/if}
{#if src}<img src={src} alt="" loading="lazy" />{:else}<span class="ph"><Icon name={icon} size={26} /></span>{/if}
</span>
<span class="title">{title}</span>
<span class="sub">{sub}</span>
+5 -1
View File
@@ -84,7 +84,11 @@ export function visible(node, path) {
},
{ rootMargin: "320px" },
);
io.observe(node);
/* a display:contents wrapper (Cover.svelte in a row) has no box of
its own — observe the nearest element that has one */
let box = node;
while (box && box !== document.body && !box.getClientRects().length) box = box.parentElement;
io.observe(box || node);
};
arm(path);
return {
+142
View File
@@ -0,0 +1,142 @@
/* Importing a playlist on the phone — the desktop's PlaylistImportModal's
rules, on the doors that need no tool and no file: a link (YouTube ·
Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines,
a timestamped setlist, or pasted track links), a YouTube DJ set's posted
tracklist. The backend stages a draft (parsing and resolution against
the library); nothing is written until commit. While the review is up
the stream rows are verified against the catalog — the desktop's
verifyStreams, verbatim: the ISRC first, then the hardened name walk —
so a committed row plays as a catalog track with artwork. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import { refreshPlaylists } from "./lib.svelte.js";
export const importer = $state({
door: null, // "link" | "text" | "youtube"
source: "", // what was pasted
busy: false,
error: "",
progress: null, // { done, total, waiting } while pasted links resolve
draft: null, // the backend's ImportDraft
name: "",
includeUnresolved: true,
checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none"
committing: false,
});
export const FORMAT_LABEL = {
m3u: "M3U", pls: "PLS", xspf: "XSPF", csv: "CSV", soulstream: "SoulStream backup",
text: "Pasted text", folder: "Folder", youtube: "YouTube", "youtube-playlist": "YouTube playlist",
spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer",
};
/** [door, icon, title, hint] — the desktop's Import menu, minus the file
* and folder doors (the files are on the desktop). */
export const DOORS = [
["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"],
["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"],
["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"],
];
let gen = 0;
/** Open a door: the backend reads and resolves, the review shows the draft. */
export async function start(door, source) {
const g = ++gen;
Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false });
try {
const draft = door === "link" ? await api.playlistImportLink(source)
: door === "youtube" ? await api.playlistImportYoutube(source)
: await api.playlistImportText(source);
if (g !== gen) return;
importer.draft = draft;
importer.name = draft?.name ?? "";
importer.busy = false;
if (draft?.entries?.length) queueMicrotask(() => void verifyStreams(g));
} catch (e) {
if (g === gen) { importer.error = String(e); importer.busy = false; }
}
}
/** The rows commit will send: streams are first-class; the switch only
* gates the TRUE unknowns. */
export function kept() {
const w = importer.draft;
if (!w) return [];
return importer.includeUnresolved ? w.entries : w.entries.filter((e) => e.resolution !== "unresolved");
}
export function checkedFound() {
return Object.values(importer.checks).filter((v) => v && v !== "none" && v !== "checking").length;
}
/** Make the playlist. Returns { id, name }, or null (the error is on the store). */
export async function commit() {
const w = importer.draft;
if (!w || importer.committing) return null;
const finalName = (importer.name || w.name || "Imported playlist").trim();
importer.committing = true;
importer.error = "";
try {
const rows = kept().map((e) => $state.snapshot(e));
const id = await api.playlistImportCommit(finalName, w.description ?? null, w.kind, rows, w.cover_url ?? null);
++gen; // the verification walk stops here
await refreshPlaylists();
return { id, name: finalName };
} catch (e) {
importer.error = String(e);
return null;
} finally { importer.committing = false; }
}
export function cancel() {
++gen;
Object.assign(importer, { busy: false, draft: null, error: "", progress: null, checks: {} });
}
let bound = false;
export async function bindImporter() {
if (bound) return;
bound = true;
try {
/* the pasted-links door looks each link up, a page each */
await listen("playlist:link-progress", (ev) => { if (importer.busy) importer.progress = ev.payload || null; });
} catch { /* not a phone */ }
}
async function verifyStreams(g) {
const w = importer.draft;
if (!w) return;
for (const e of w.entries) {
if (e.resolution !== "stream") continue;
if (g !== gen) return;
importer.checks[e.ordinal] = "checking";
try {
/* anchor-exact first: a row that carries the recording's ISRC
beats any name matcher; a miss falls through to the walk */
let hit = null;
let anchored = false;
if (e.isrc) {
hit = await api.catalogTrackByIsrc(e.isrc);
if (g !== gen) return;
anchored = !!hit;
}
if (!hit) {
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{ title: e.title, artist: e.artist, duration_secs: e.duration_secs ?? null },
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
if (g !== gen) return;
}
if (hit) {
/* the catalog identity rides into the committed row */
e.track_id = Number(hit.id);
e.art_url = hit.cover ?? null;
if (!e.album) e.album = hit.album ?? null;
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
importer.checks[e.ordinal] = { album: hit.album ?? "", cover: hit.cover ?? null, anchored };
} else {
importer.checks[e.ordinal] = "none";
}
} catch {
if (g === gen) importer.checks[e.ordinal] = "none";
}
await new Promise((r) => setTimeout(r, 250));
}
}
+6
View File
@@ -106,6 +106,8 @@ export async function watchLibrary() {
await refreshRecents();
}, 600);
});
/* a playlist mutation (the desktop's commands emit it) re-lists them */
await listen("soulseek:playlists-changed", () => { void refreshPlaylists(); });
} catch { /* not a phone */ }
}
@@ -121,3 +123,7 @@ export async function loadLibrary() {
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
lib.loaded = true;
}
export async function refreshPlaylists() {
try { lib.playlists = (await api.listPlaylists()) || []; } catch { /* keep what we have */ }
}
+27 -3
View File
@@ -58,9 +58,22 @@ function backables() {
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
}
/* make the history depth equal the number of layers the back gesture can peel */
/* make the history depth equal the number of layers the back gesture can
peel. Applied once per microtask: a sheet closed and a screen popped in
the same handler (a confirmed delete) is ONE traversal, go(-2) — two
go(-1)s in a tick lose one in Chrome, and the mirror drifts until a
later go(-k) walks off the first entry and the page is gone. */
let syncQueued = false;
function sync() {
if (!booted) return;
if (!booted || syncQueued) return;
syncQueued = true;
queueMicrotask(() => { syncQueued = false; applySync(); });
}
function applySync() {
/* a traversal (history.go) completes in a LATER task; a pushState before
then lands on the entry being left and the traversal strands it as
forward history. Wait: the popstate that completes it re-syncs. */
if (suppress > 0) return;
const want = backables();
while (depth < want) {
depth++;
@@ -78,7 +91,7 @@ export function bootNav() {
booted = true;
try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ }
window.addEventListener("popstate", () => {
if (suppress > 0) { suppress--; return; }
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
depth = Math.max(0, depth - 1);
if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
@@ -143,6 +156,17 @@ export function patch(c) {
Object.assign(top().c, c);
}
/** Swap the top screen for another — a review that became the thing it
* made. Plays the push transition; the history depth is unchanged. */
export function replace(n, c = {}) {
nav.half = null;
const s = nav.stacks[nav.tab];
s[s.length - 1] = { n, c, scroll: 0 };
nav.tr = "push";
nav.seq++;
sync();
}
export function openSheet(fromEl = null) {
if (nav.sheet) return;
let rect = null;
+19
View File
@@ -0,0 +1,19 @@
/* A neutral toast with one optional action — the desktop's follow-toast
pill, the one Undo rides on. The red .toast stays for errors
(player.error / lib.error); this one states a fact and goes away. */
export const notice = $state({ text: "", action: null, seq: 0 });
let timer = 0;
/** `action` is { label, fn } or null. */
export function note(text, action = null, ms = 3200) {
clearTimeout(timer);
notice.text = text;
notice.action = action;
notice.seq++;
timer = setTimeout(clearNote, ms);
}
export function clearNote() {
clearTimeout(timer);
notice.text = "";
notice.action = null;
}
+11 -3
View File
@@ -121,7 +121,7 @@ async function startEntry(e, startSecs = null) {
armedPath = null;
player.track = e.t;
player.album = e.album;
player.position = startSecs || 0;
player.position = startSecs ?? e.t.startSecs ?? 0;
player.duration = e.t.duration_secs ?? null;
player.status = "buffering";
player.needsResource = false;
@@ -162,7 +162,7 @@ async function startEntry(e, startSecs = null) {
return;
}
try {
await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs);
await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs ?? e.t.startSecs ?? null);
} catch (err) {
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
return;
@@ -183,6 +183,7 @@ async function startEntry(e, startSecs = null) {
one — 12s for the first peer (an indirect handshake honestly takes ~10),
8s for the rest — and when they are spent the queue moves on. */
let watchdog = null;
let endFired = 0; /* the generation whose set-slice end bound has fired */
function armWatchdog(g, e) {
clearTimeout(watchdog);
const first = !e.t.tried;
@@ -383,7 +384,9 @@ export function playEntries(entries, at = 0, from = "") {
player.queue = entries;
player.qi = at;
player.from = from;
return startEntry(entries[at]);
/* the REACTIVE entry, not the raw one: a path-less first entry gets its
stream and path from the resolver, and the bar must see them land */
return startEntry(player.queue[at]);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
@@ -577,6 +580,11 @@ export async function bindEvents() {
if (typeof p.secs === "number") {
player.position = p.secs;
listenTick(p.secs);
/* a set-recording slice (a setlist row) ends at the next cue */
if (player.track?.endSecs != null && p.secs >= player.track.endSecs && endFired !== gen) {
endFired = gen;
next();
}
}
if (p.duration != null) player.duration = p.duration;
break;
+332
View File
@@ -0,0 +1,332 @@
/* Playlists on the phone: the desktop's Playlists shelf and Playlist page
(src/views/Playlists.svelte, Playlist.svelte), their rules kept — pinned
first; the cover or the composed mosaic, fetched once per revision; Play
queues the rows that CAN play (a present local file, or a stream row the
resolver finds on reach — hooks.resolveEntry, the same path a record's
later tracks take); a set-recording slice carries its bounds; a removal
is undoable for seven seconds; every mutation re-lists through
soulseek:playlists-changed, which the desktop's commands emit. */
import * as api from "../lib/api.js";
import { lib, refreshPlaylists } from "./lib.svelte.js";
import { player, playEntries } from "./player.svelte.js";
import { streamSong } from "./resolve.svelte.js";
import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, normText } from "../lib/sources.js";
import { slsk } from "./slsk.svelte.js";
import { note } from "./notice.svelte.js";
export const pls = $state({
/** "id:revision" → data URL, or null once the backend said there is none */
covers: {},
/** the playlist page that is open: { id, items } — the bar's sheet reads it */
current: null,
/** the last removal: { playlistId, rows: [{ item, position }], label } */
undo: null,
});
/* ── covers ──────────────────────────────────────────────────────────── */
const coverKey = (p) => p.id + ":" + p.revision;
const asking = new Set();
/** The custom cover or the four-cover mosaic, keyed by revision so an
* edit re-composes it and a re-render never refetches. */
export function coverFor(p) {
if (!p) return null;
const k = coverKey(p);
const have = pls.covers[k];
if (have !== undefined) return have;
if (!asking.has(k)) {
asking.add(k);
api.playlistCover(p.id, 360)
.then((u) => { pls.covers[k] = u || null; }, () => { pls.covers[k] = null; })
.finally(() => asking.delete(k));
}
return null;
}
/* ── rows ────────────────────────────────────────────────────────────── */
export const isSetlist = (p) => p?.kind === "setlist";
/** Rows Play/Shuffle can queue: present local files plus stream rows. */
export const queueable = (item) => !!(item.path && !item.missing) || item.kind === "stream";
export const unresolvedCount = (items) => items.filter((i) => i.kind === "unresolved").length;
export const missingCount = (items) => items.filter((i) => !!i.missing).length;
export const totalSecs = (items) => items.reduce((n, i) => n + (i.duration_secs ?? 0), 0);
/* Where each set-recording slice ends: the NEXT stamped row in the full
(unfiltered) order bounds it; the last slice runs to the file's end. */
function segmentEnds(items) {
const ends = new Map();
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind !== "segment" || it.start_ms == null) continue;
const next = items.slice(i + 1).find((n) => n.start_ms != null);
ends.set(it.id, next ? next.start_ms : null);
}
return ends;
}
/** The record a row belongs to, as the player's `album`: the library's
* own when the row names one it holds, else a shell — a stream row's
* cover is a catalog URL, which the art cache routes through the proxy. */
function albumOfItem(item, p) {
const k = String(item.artist || "").toLowerCase();
const own = item.album ? lib.albums.find((a) => a.title === item.album && a.artist.toLowerCase() === k) : null;
if (own) return own;
return {
id: null, catalogId: null,
title: item.album || p?.name || null,
artist: item.artist,
art_path: item.path ? (item.art_path ?? null) : (item.art_url ?? null),
};
}
/** A row as the player's entry. A local file plays as itself; a stream row
* is a path-less entry with its catalog identity, resolved on reach; a set
* slice carries where in the recording it starts and ends. */
export function entryOf(item, p, ends = null) {
const local = !!item.path && !item.missing;
const tid = item.track_id != null && item.track_id !== "" ? Number(item.track_id) : NaN;
const endMs = ends ? ends.get(item.id) : undefined;
const segment = item.kind === "segment" && local && item.start_ms != null
? { start: item.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
: null;
return {
t: {
id: "pl" + item.id,
title: item.title,
artist: item.artist,
path: local ? item.path : null,
duration_secs: item.duration_secs ?? (segment && segment.end != null ? Math.round(segment.end - segment.start) : null),
stream: null,
catalog: local ? null : { trackId: Number.isFinite(tid) ? tid : null, albumId: null, expectSecs: item.duration_secs ?? null },
alts: [],
startSecs: segment ? segment.start : null,
endSecs: segment ? segment.end : null,
},
album: albumOfItem(item, p),
plItemId: item.id,
};
}
/** Play the playlist from a row (or its top), as ONE queue in order. */
export function playFrom(p, items, item = null) {
const rows = items.filter(queueable);
if (!rows.length) return false;
let at = 0;
if (item) {
const i = rows.findIndex((r) => r.id === item.id);
if (i >= 0) at = i;
}
const ends = segmentEnds(items);
void playEntries(rows.map((r) => entryOf(r, p, ends)), at, p?.name ?? "Playlist");
return true;
}
/** Shuffle on, a random row first — playEntries lays the rest out shuffled. */
export function shufflePlay(p, items) {
const rows = items.filter(queueable);
if (!rows.length) return false;
player.shuffle = true;
return playFrom(p, items, rows[Math.floor(Math.random() * rows.length)]);
}
/** A tap on a row, the desktop's rowClick: a queueable row plays within
* the playlist; a weak row with a name still streams one-off; a bare
* placeholder ("ID") has nothing to search for — false, and the caller
* offers the sheet. */
export function rowClick(p, items, item) {
if (queueable(item)) return playFrom(p, items, item);
if (item.title && item.artist && item.title.toLowerCase() !== "id") {
const tid = Number(item.track_id);
streamSong(item.artist, item.title, {
cover: item.art_url ?? undefined,
trackId: Number.isFinite(tid) ? tid : undefined,
album: item.album ?? undefined,
expectSecs: item.duration_secs ?? undefined,
}).catch((e) => { player.error = String(e); });
return true;
}
return false;
}
/* ── the shelf ───────────────────────────────────────────────────────── */
export function shelfTotals(list) {
let tracks = 0;
let secs = 0;
for (const p of list) {
tracks += p.track_count ?? 0;
secs += p.duration_secs ?? 0;
}
return { tracks, secs };
}
/* ── mutations: each re-lists, so every screen showing the playlist follows ── */
export async function createPlaylist(name) {
const id = await api.createPlaylist(name);
await refreshPlaylists();
return id;
}
export async function renamePlaylist(p, name) {
await api.renamePlaylist(p.id, name);
await refreshPlaylists();
}
export async function describePlaylist(p, text) {
await api.setPlaylistDescription(p.id, text || null);
await refreshPlaylists();
}
export async function togglePin(p) {
await api.setPlaylistPinned(p.id, !p.pinned);
await refreshPlaylists();
}
export async function duplicatePlaylist(p) {
const id = await api.duplicatePlaylist(p.id, `${p.name} copy`);
await refreshPlaylists();
return id;
}
export async function deletePlaylist(p) {
await api.deletePlaylist(p.id);
await refreshPlaylists();
}
/* ── removal, undoable ───────────────────────────────────────────────── */
let undoTimer = 0;
function stashUndo(playlistId, rows, label) {
clearTimeout(undoTimer);
/* the stash pins the playlist it came from: navigating away and
tapping Undo must never restore rows into a DIFFERENT playlist */
pls.undo = { playlistId, rows, label };
undoTimer = setTimeout(() => { pls.undo = null; }, 7000);
note(label, { label: "Undo", fn: applyUndo }, 7000);
}
/** Remove `rows` (items of `items`, the page's full order) from `p`. */
export async function removeItems(p, items, rows) {
if (!rows.length) return;
const stash = rows.map((item) => ({ item, position: items.findIndex((i) => i.id === item.id) }));
await api.playlistRemoveItems(p.id, rows.map((r) => r.id));
const allUnresolved = rows.every((r) => r.kind === "unresolved");
stashUndo(p.id, stash, rows.length === 1 && !allUnresolved
? `Removed “${rows[0].title}”`
: `Removed ${rows.length} ${allUnresolved ? "unresolved " : ""}${rows.length === 1 ? "entry" : "entries"}`);
await refreshPlaylists();
}
export async function applyUndo() {
const stash = pls.undo;
pls.undo = null;
clearTimeout(undoTimer);
if (!stash) return;
/* ascending original positions, so each insert-at lands true */
for (const { item, position } of [...stash.rows].sort((a, b) => a.position - b.position)) {
await api.playlistAddItems(stash.playlistId, [{
title: item.title, artist: item.artist, album: item.album,
duration_secs: item.duration_secs, path: item.path, art_path: item.art_path,
start_ms: item.start_ms, source_line: item.source_line, note: item.note,
track_id: item.track_id, art_url: item.art_url,
}], position);
}
await refreshPlaylists();
}
/* ── "Add to playlist" from the sheets ───────────────────────────────── */
const isUrl = (s) => /^https?:/i.test(String(s || ""));
/** Queue entries ({ t, album }) → the insert rows: a file on the phone is
* a local row; a streamed or catalog track keeps its identity and cover
* URL and becomes a stream row. */
export function rowsOfEntries(entries) {
return entries.map(({ t, album }) => {
const local = !!t.path && !t.stream;
const cover = t.catalog?.cover ?? (isUrl(album?.art_path) ? album.art_path : null);
return {
title: t.title,
artist: t.artist || album?.artist || "",
album: album?.title ?? null,
duration_secs: t.duration_secs ?? null,
path: local ? t.path : null,
art_path: local && album?.art_path && !isUrl(album.art_path) ? album.art_path : null,
track_id: t.catalog?.trackId != null ? String(t.catalog.trackId) : null,
art_url: local ? null : cover,
};
});
}
export async function addEntries(p, entries) {
const rows = rowsOfEntries(entries);
if (!rows.length) return 0;
await api.playlistAddItems(p.id, rows);
await refreshPlaylists();
note(`Added ${rows.length === 1 ? "“" + rows[0].title + "”" : rows.length + " tracks"} to ${p.name}`);
return rows.length;
}
/* ── the bulk actions: the desktop's runPlaylistBulk, its rules kept ──
"cached" files every already-streamed track into the library, nothing
downloaded — only a byte-complete cache counts; "download" finds a
source for every stream row not in the library yet (the remembered one
first, else the ladder) and hands it to the download pipeline. Deduped:
the same track twice must not download twice. */
export const bulk = $state({
active: false,
mode: null, // "cached" | "download"
playlistId: null,
done: 0,
total: 0,
organized: 0, // filed into the library at once (was fully cached)
queued: 0, // handed to the download pipeline
missed: 0, // no complete cache ("cached") / no source ("download")
summary: null, // the last run's one line, until dismissed
});
let bulkCancel = false;
export function cancelBulk() { bulkCancel = true; }
export function dismissBulk() { bulk.summary = null; }
export const bulkable = (items) => items.filter((i) => i.kind === "stream" && i.artist && i.title).length;
export async function runBulk(p, items, mode) {
if (bulk.active) return false;
const seen = new Set();
const want = items.filter((r) => {
if (r.kind !== "stream" || !r.artist || !r.title) return false;
const key = `${normText(r.artist)}|${normText(r.title)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
bulkCancel = false;
Object.assign(bulk, { active: true, mode, playlistId: p.id, done: 0, total: want.length, organized: 0, queued: 0, missed: 0, summary: null });
try {
for (const row of want) {
if (bulkCancel) break;
try {
const tid = Number(row.track_id);
const known = await cachedStreamSourceFor(Number.isFinite(tid) ? tid : null, row.artist, row.title);
let src = known ? { username: known.username, path: known.path, size: known.size } : null;
if (mode === "cached") {
/* a prefix is not the track, and this mode promised "no downloading" */
const len = src ? await api.streamCacheLen(src.username, src.path) : null;
if (!src || !src.size || len !== src.size) { bulk.missed += 1; continue; }
} else if (!src) {
if (slsk.connection !== "online") { bulk.missed += 1; continue; }
const { responses, artistIncluded } = await resolveSources(
`${row.artist} — ${row.title}`,
[`${row.artist} ${simplifyTitle(row.title)}`, simplifyTitle(row.title)],
(rs) => songCandidates(rs, row.title, false, row.album ?? null, row.duration_secs ?? null, row.artist).length,
row.artist,
null,
);
const best = songCandidates(responses, row.title, false, row.album ?? null, row.duration_secs ?? null, row.artist, !artistIncluded)[0];
if (!best) { bulk.missed += 1; continue; }
src = { username: best.username, path: best.file.path, size: best.file.size };
}
const [organized, queued] = await api.saveToLibrary([src]);
bulk.organized += organized;
bulk.queued += queued;
if (organized === 0 && queued === 0) bulk.missed += 1;
} catch {
bulk.missed += 1;
} finally {
bulk.done += 1;
}
}
const parts = [];
if (bulk.organized) parts.push(`${bulk.organized} saved to library`);
if (bulk.queued) parts.push(`${bulk.queued} downloading`);
if (bulk.missed) parts.push(`${bulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`);
if (bulkCancel) parts.push("stopped early");
bulk.summary = parts.length ? parts.join(" · ") : "Nothing to do";
} finally {
bulk.active = false;
}
return true;
}
+112
View File
@@ -0,0 +1,112 @@
<script>
/* The import review — the desktop modal's review, on a screen: what
the door read (the format, the source), the counts, the warnings,
every row with its verdict (in library / matched in the catalog /
stream / set audio / unresolved), the name, the keep-unresolved
switch, and Create. Creating swaps this screen for the playlist. */
import Icon from "../../components/Icon.svelte";
import { importer, FORMAT_LABEL, kept, checkedFound, commit, cancel } from "../importer.svelte.js";
import { openHalf, back, replace } from "../nav.svelte.js";
import { mmss } from "../util.js";
const w = $derived(importer.draft);
const rows = $derived(kept());
const n = $derived(rows.length);
const found = $derived(checkedFound());
function stamp(ms) {
const s = Math.floor((ms || 0) / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (h ? h + ":" + String(m).padStart(2, "0") : String(m)) + ":" + String(sec).padStart(2, "0");
}
/* [tag class, label] — the desktop's evidence column */
function verdict(e) {
if (e.resolution === "local") return ["ok", e.matched_by === "path" || e.matched_by === "file" ? "file" : e.matched_by === "relinked" ? "relinked" : "tags"];
if (e.resolution === "stream") {
const c = importer.checks[e.ordinal];
if (c === "checking") return ["st dim", "matching…"];
if (c && c !== "none") return ["st", "catalog"];
if (c === "none") return ["un", "not in catalog"];
return ["st dim", "stream"];
}
if (e.resolution === "segment") return ["st dim", "set audio"];
return ["un", "unresolved"];
}
function rename() {
openHalf("prompt", { title: "Playlist name", value: importer.name || w?.name || "Imported playlist", placeholder: "Name", ok: "Save", onsubmit: (v) => { importer.name = v; } });
}
async function create() {
const r = await commit();
if (r) replace("playlist", { id: r.id });
}
function leave() { cancel(); back(); }
const waitLine = $derived(importer.door === "link" ? "Reading the link…" : importer.door === "youtube" ? "Reading the set's tracklist…" : "Reading the tracklist…");
</script>
{#if importer.busy}
<div class="imp-wait">
<span class="ring"></span>
<b>{waitLine}</b>
{#if importer.progress}
<span>{importer.progress.waiting ? `Rate-limited — carrying on from ${importer.progress.done} of ${importer.progress.total}…` : `Reading track ${importer.progress.done} of ${importer.progress.total}…`}</span>
{:else}
<span>Parsing and matching against your library.</span>
{/if}
<button class="btn btn-ghost" onclick={leave}>Cancel</button>
</div>
{:else if importer.error && !w}
<div class="stub"><b>That didn't import.</b>{importer.error}</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={leave}>Back</button></div>
{:else if w}
<div class="imp-head">
<div class="stats">
<span class="s">{FORMAT_LABEL[w.format] ?? w.format}</span>
{#if w.kind === "setlist"}<span class="s">Setlist</span>{/if}
</div>
<div class="imp-src">{w.source_name}</div>
<div class="imp-counts"><b class="ok">{w.resolved_count}</b> in library{#if w.stream_count > 0}{" · "}<b class="st">{found}</b>/{w.stream_count} in catalog{/if}{#if w.unresolved_count > 0}{" · "}<b class="un">{w.unresolved_count}</b> unknown{/if}</div>
{#if w.warnings?.length}
<div class="imp-warn">{#each w.warnings as x, i (i)}<div>{x}</div>{/each}</div>
{/if}
{#if w.format === "spotify" && w.entries.length >= 100}
<div class="imp-warn">Spotify shows the first 100 here. For the rest, paste the playlist's track links as a tracklist.</div>
{/if}
</div>
<div class="tracks imp-rows">
{#each w.entries as e (e.ordinal)}
{@const [cls, label] = verdict(e)}
<div class="trk" class:unresolved={e.resolution === "unresolved"} class:excluded={!importer.includeUnresolved && e.resolution === "unresolved"}>
<span class="n" class:stamped={w.kind === "setlist"}>{w.kind === "setlist" ? stamp(e.start_ms) : e.ordinal + 1}</span>
<span class="b"><span class="t">{e.title}</span><span class="m">{e.artist}{e.album ? " — " + e.album : ""}</span></span>
<span class="tag {cls}">{label}</span>
<span class="d">{e.duration_secs != null ? mmss(e.duration_secs) : ""}</span>
</div>
{/each}
</div>
<div class="group imp-form">
<button class="row" onclick={rename}>
<span class="lead"><Icon name="pencil" size={20} /></span>
<span class="b"><span class="t">{importer.name || w.name || "Imported playlist"}</span><span class="m">the playlist's name</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if w.unresolved_count > 0}
<button class="row" onclick={() => (importer.includeUnresolved = !importer.includeUnresolved)}>
<span class="lead"><Icon name="alert" size={20} /></span>
<span class="b"><span class="t">Keep unresolved entries</span><span class="m">as placeholders, to repair on the desktop</span></span>
<span class="sw" class:on={importer.includeUnresolved}><span></span></span>
</button>
{/if}
</div>
{#if importer.error}<div class="sec-note" style="color: #ffb4b4; padding-top: 10px">{importer.error}</div>{/if}
<div class="imp-foot">
<button class="btn btn-ghost" onclick={leave}>Cancel</button>
<button class="btn btn-fill" disabled={importer.committing || !n} onclick={create}>{importer.committing ? "Creating…" : `Create playlist (${n} ${n === 1 ? "track" : "tracks"})`}</button>
</div>
{:else}
<div class="stub"><b>Nothing to review.</b>Open a door from the Playlists shelf.</div>
<div class="imp-foot"><button class="btn btn-ghost wide" onclick={back}>Back</button></div>
{/if}
<div class="endpad"></div>
+2 -13
View File
@@ -6,6 +6,7 @@
import Release from "../Release.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte";
import * as api from "../../lib/api.js";
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
import { push, go, patch, openHalf } from "../nav.svelte.js";
@@ -136,19 +137,7 @@
{:else if tab === "tracks"}
<Tracks {entry} />
{:else if tab === "playlists"}
{#if !lib.playlists.length}
<div class="stub"><b>No playlists on this phone yet.</b>Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
{/if}
{#each lib.playlists as p (p.id)}
<button class="row" onclick={() => push("playlist", { id: p.id }, screenEl)}>
<span class="art"><Icon name="playlist" size={20} /></span>
<span class="b">
<span class="t">{p.name}</span>
<span class="m">{p.description ? p.description + " · " : ""}{p.track_count} track{p.track_count === 1 ? "" : "s"}</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
<PlaylistShelf {screenEl} />
{:else if tab === "gaps"}
{#if !shelves.length}
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
+108 -41
View File
@@ -1,70 +1,137 @@
<script>
/* A playlist, in the record page's shape. Rows without a local file
(streams, unresolved imports) keep their place and say so. */
/* A playlist — the desktop's Playlist.svelte at phone scale, in the
record page's shape: the cover or the mosaic, the kind and a Pinned
chip, the name, the description, the meta line with its warnings,
Play / Shuffle / more, a filter, and the rows. A local file plays as
itself; a stream row finds its copy on its turn (the resolver); an
unresolved row says so, and its sheet is the way to deal with it.
The rows are re-read whenever the playlist's revision moves — a
removal, an undo, an add from a sheet. */
import Icon from "../../components/Icon.svelte";
import Cover from "../Cover.svelte";
import * as api from "../../lib/api.js";
import { lib } from "../lib.svelte.js";
import { player, playEntries } from "../player.svelte.js";
import { player } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { mmss, longDur, shuffled } from "../util.js";
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";
import { mmss, longDur } from "../util.js";
let { id } = $props();
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
let items = $state([]);
let cover = $state("");
let loaded = $state(false);
let gen = 0;
$effect(() => {
const p = pl;
if (!p) return;
api.playlistItems(p.id).then((r) => { items = r || []; }, (e) => { player.error = String(e); });
api.playlistCover(p.id, 360).then((u) => { cover = u || ""; }, () => {});
void p.revision;
const g = ++gen;
api.playlistItems(p.id).then(
(r) => { if (g === gen) { items = r || []; loaded = true; } },
(e) => { if (g === gen) { loaded = true; player.error = String(e); } },
);
});
/* the bar's sheet (playlist-more) reads the open page's rows here */
$effect(() => {
const p = pl;
if (!p) return;
pls.current = { id: p.id, items };
return () => { if (pls.current?.id === p.id) pls.current = null; };
});
const total = $derived(items.reduce((n, i) => n + (i.duration_secs || 0), 0));
const playable = $derived(items.filter((i) => i.path).length);
function entryOf(x) {
return {
t: { id: "pl" + x.id, title: x.title, artist: x.artist, path: x.path, duration_secs: x.duration_secs ?? null },
album: { id: null, title: x.album || pl.name, artist: x.artist, art_path: x.art_path || null },
};
}
function playFrom(i) {
const es = items.slice(i).filter((x) => x.path).map(entryOf);
if (es.length) playEntries(es, 0, pl.name);
}
function shuffle() {
const es = shuffled(items.filter((x) => x.path).map(entryOf));
if (es.length) { player.shuffle = true; playEntries(es, 0, pl.name); }
const cover = $derived(pl ? coverFor(pl) : null);
let q = $state("");
const norm = (s) => String(s ?? "").toLowerCase();
const visible = $derived.by(() => {
const k = norm(q.trim());
if (!k) return items;
return items.filter((i) => norm(i.title).includes(k) || norm(i.artist).includes(k) || norm(i.album).includes(k));
});
/* setlist cue times show only when EVERY row carries one — the
import's own "coherent or not at all" rule, gated on the full list */
const stamped = $derived(items.length > 0 && items.every((i) => i.start_ms != null));
const unresolved = $derived(unresolvedCount(items));
const missing = $derived(missingCount(items));
const total = $derived(totalSecs(items));
const playable = $derived(items.filter(queueable).length);
const nowItemId = $derived(player.queue[player.qi]?.plItemId ?? null);
function stamp(ms) {
const s = Math.floor((ms || 0) / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (h ? h + ":" + String(m).padStart(2, "0") : String(m)) + ":" + String(sec).padStart(2, "0");
}
function sheet(item) { openHalf("pltrack", { playlist: pl, items, item }); }
function tap(item) { if (!rowClick(pl, items, item)) sheet(item); }
</script>
{#if pl}
<div class="rec-head">
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={48} />{/if}</div>
<div class="rec-head pl">
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={56} />{/if}</div>
<div class="rec-meta">
<div class="kind">{isSetlist(pl) ? "Setlist" : "Playlist"}{#if pl.pinned}<span class="pin-chip"><Icon name="pin" size={10} strokeWidth={3} />Pinned</span>{/if}</div>
<div class="rec-title">{pl.name}</div>
<div class="rec-line">{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{playable < items.length ? ` · ${playable} on this phone` : ""}</div>
{#if pl.description}<div class="rec-line" style="margin-top: 8px; color: var(--text-secondary)">{pl.description}</div>{/if}
{#if pl.description}<div class="rec-desc">{pl.description}</div>{/if}
<div class="rec-line">{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{#if unresolved}{" · "}<span class="warn">{unresolved} unresolved</span>{/if}{#if missing}{" · "}<span class="warn">{missing} missing</span>{/if}</div>
<div class="rec-acts">
<button class="iconbtn muted" onclick={shuffle} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
<button class="disc lg" onclick={() => playFrom(0)} aria-label="Play"><Icon name="play" size={26} /></button>
<button class="iconbtn muted" onclick={() => shufflePlay(pl, items)} disabled={!playable} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
<button class="disc lg" onclick={() => playFrom(pl, items)} disabled={!playable} aria-label="Play"><Icon name="play" size={26} /></button>
<button class="iconbtn muted" onclick={() => openHalf("playlist-more", { playlist: pl, items })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
{#if bulk.playlistId === pl.id && (bulk.active || bulk.summary)}
<div class="bulkline">
{#if bulk.active}
<span class="ring"></span>
<span>{bulk.mode === "cached" ? "Adding streamed tracks to the library" : "Downloading tracks"} — {bulk.done}/{bulk.total}{bulk.organized ? ` · ${bulk.organized} saved` : ""}{bulk.queued ? ` · ${bulk.queued} downloading` : ""}{bulk.missed ? ` · ${bulk.missed} missed` : ""}</span>
<button class="bulkx" onclick={cancelBulk} aria-label="Stop"><Icon name="x" size={14} /></button>
{:else}
<Icon name="check" size={14} />
<span>{bulk.summary}</span>
<button class="bulkx" onclick={dismissBulk} aria-label="Dismiss"><Icon name="x" size={14} /></button>
{/if}
</div>
{/if}
</div>
</div>
<div class="tracks">
{#each items as x, i (x.id)}
<button class="trk" class:playing={x.path && player.track?.path === x.path} onclick={() => x.path && playFrom(i)} oncontextmenu={(e) => { e.preventDefault(); if (x.path) openHalf("track", entryOf(x)); }} style:opacity={x.path ? 1 : 0.5}>
<span class="n">{i + 1}</span>
<span class="b">
<span class="t">{x.title}</span>
<span class="m">{x.artist}{x.album ? " · " + x.album : ""}{x.path ? "" : " · not on this phone"}</span>
</span>
<span class="d">{mmss(x.duration_secs)}</span>
</button>
{/each}
</div>
{#if loaded && !items.length}
<div class="stub"><b>This playlist is empty.</b>Add tracks from a record's or a track's sheet — long-press one and choose Add to playlist.</div>
{:else}
{#if items.length}
<div class="sfield plfilter">
<Icon name="search" size={18} />
<input value={q} oninput={(e) => (q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" />
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
</div>
{/if}
{#if loaded && items.length && !visible.length}
<div class="stub"><b>Nothing here matches “{q}”.</b></div>
{/if}
<div class="tracks pl-rows">
{#each visible as item, i (item.id)}
{@const now = nowItemId != null && nowItemId === item.id}
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
<span class="n" class:stamped={isSetlist(pl) && stamped}>{#if isSetlist(pl) && stamped}<span class="stamp">{stamp(item.start_ms)}</span>{:else}{i + 1}{/if}</span>
<span class="thumb">
{#if item.path && item.art_path}<Cover path={item.art_path} size={14} />
{:else if item.art_url}<img src={item.art_url} alt="" loading="lazy" />
{:else}<Icon name="music" size={14} />{/if}
</span>
<span class="b">
<span class="t">{item.title}{#if item.kind === "unresolved"} <span class="tag un">unresolved</span>{:else if item.kind === "segment" && !item.missing} <span class="tag seg">set audio</span>{:else if item.missing} <span class="tag miss">file missing</span>{/if}</span>
{#if item.artist || item.album}<span class="m">{item.artist}{item.artist && item.album ? " — " : ""}{item.album ?? ""}</span>{/if}
</span>
<span class="d">{item.duration_secs != null ? mmss(item.duration_secs) : "–:––"}</span>
<span class="r" role="button" tabindex="0" aria-label="More" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); sheet(item); }}><Icon name="more" size={17} /></span>
</button>
{/each}
</div>
{/if}
{:else}
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>That playlist is gone.</b></div>
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>Playlist not found.</b>It may have been deleted.</div>
{/if}
<div class="endpad"></div>
+49 -4
View File
@@ -8,6 +8,7 @@
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
import { refreshSourcePrefs } from "../../lib/sources.js";
let cfg = $state(null);
let scanning = $state(false);
@@ -17,7 +18,7 @@
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
async function toggleNormalization() {
const next = !normalization;
try { await api.setSettings({ volume_normalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
try { await api.setSettings({ volumeNormalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
/* the library folder: picked on the phone, set as the app's download
dir (the folder IS the library), rescanned. A folder in shared storage
@@ -39,7 +40,7 @@
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
await api.setSettings({ download_dir: r.path });
await api.setSettings({ downloadDir: r.path });
cfg = await api.getSettings();
await rescan();
} catch (e) {
@@ -53,6 +54,24 @@
scanning = false;
}
/* the desktop's Source quality settings (Settings → Sources): a window
per mode and the priority inside it; the resolver re-reads them */
const MODES = [["stream", "Streaming"], ["download", "Downloads"]];
const MAX_Q = [["any", "No limit"], ["lossy_only", "Lossy only"]];
const MIN_Q = [["any", "Any quality"], ["kbps192", "192 kbps and up"], ["kbps256", "256 kbps and up"], ["kbps320", "320 kbps and up"], ["lossless", "Lossless only"]];
const PRI = [["quality", "Best quality"], ["speed", "Fastest transfer"]];
const PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
const prefsOf = (mode) => ({ ...PREF_DEFAULTS, ...((mode === "stream" ? cfg?.stream_source_prefs : cfg?.download_source_prefs) ?? {}) });
async function setPref(mode, field, value) {
if (prefsOf(mode)[field] === value) return;
const next = { ...prefsOf(mode), [field]: value };
try {
await api.setSettings(mode === "stream" ? { streamSourcePrefs: next } : { downloadSourcePrefs: next });
cfg = await api.getSettings();
await refreshSourcePrefs();
} catch (e) { player.error = String(e); }
}
const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
@@ -110,6 +129,32 @@
</button>
</div>
<div class="sheet-sec">Sources</div>
<div class="sec-note">Independent for streaming and downloads. The ceiling and floor bound which peer files qualify; priority decides whether the resolver takes the best format or the fastest transfer among them.</div>
{#each MODES as [key, label] (key)}
<div class="group prefs" data-mode={key}>
<div class="pref-head">{label}</div>
<div class="pref">
<div class="pref-l"><span class="t">Maximum</span><span class="m">lossy only avoids huge lossless transfers</span></div>
<div class="chips">
{#each MAX_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).max === v} class:quiet={prefsOf(key).max !== v} onclick={() => setPref(key, "max", v)}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Minimum</span><span class="m">files below the floor are skipped; a lossy file must report its bitrate</span></div>
<div class="chips">
{#each MIN_Q as [v, l] (v)}<button class="chip" class:on={prefsOf(key).min === v} class:quiet={prefsOf(key).min !== v} onclick={() => setPref(key, "min", v)}>{l}</button>{/each}
</div>
</div>
<div class="pref">
<div class="pref-l"><span class="t">Priority</span><span class="m">among the files inside that window</span></div>
<div class="chips">
{#each PRI as [v, l] (v)}<button class="chip" class:on={prefsOf(key).priority === v} class:quiet={prefsOf(key).priority !== v} onclick={() => setPref(key, "priority", v)}>{l}</button>{/each}
</div>
</div>
</div>
{/each}
<div class="sheet-sec">This phone</div>
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
@@ -117,8 +162,8 @@
</div>
<div class="stub">
<b>Playback, storage, the network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components; the library folder you pick and Import land with them.
<b>The network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components.
</div>
<div class="endpad"></div>
+32
View File
@@ -0,0 +1,32 @@
/* A share from another app (the share sheet → the share plugin's
ShareActivity) lands in the playlist import: a playlist link opens the
link door, anything else is read as a pasted tracklist. Asked for at
boot and whenever the app comes back to the front — that is when the
hand-off has happened. */
import * as api from "../lib/api.js";
import { go, patch, push } from "./nav.svelte.js";
import { start as startImport } from "./importer.svelte.js";
const URL_RE = /https?:\/\/[^\s<>"']+/i;
let checking = false;
export async function checkShare() {
if (checking) return;
checking = true;
try {
const r = await api.pendingShare();
const text = String(r?.text || "").trim();
if (!text) return;
const url = URL_RE.exec(text)?.[0] || null;
go("library");
patch({ tab: "playlists" });
push("import", {});
void startImport(url ? "link" : "text", url || text);
} catch { /* not a phone, or the plugin is not here */ } finally { checking = false; }
}
let bound = false;
export function bindShare() {
if (bound) return;
bound = true;
void checkShare();
document.addEventListener("visibilitychange", () => { if (!document.hidden) void checkShare(); });
}
+77
View File
@@ -1258,3 +1258,80 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
transition-duration: 0.01ms !important;
}
}
/* ── playlists: the desktop's shelf and page, at phone scale ─────────── */
.plstats { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 var(--pad) 12px; }
.plstats .t-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.newpl { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 10px; font-size: 13px; font-weight: 750; flex: none; }
.plsec { padding-top: 6px; }
.plsec h2 { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
.plgrid { padding-bottom: 8px; }
/* the pinned mark on a card, the desktop's pinned-mark */
.cover-wrap .mark { position: absolute; top: 7px; left: 7px; z-index: 2; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, 0.55); color: #fff; display: grid; place-items: center; }
/* the page: the record page's shape with a smaller cover (a mosaic reads at any size) */
.rec-head.pl .rec-cover { width: 56%; max-width: 260px; }
.rec-meta .kind .pin-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 2px 7px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); letter-spacing: 0.4px; vertical-align: 1px; }
.rec-desc { margin-top: 8px; font-size: 13.5px; line-height: 1.45; color: var(--text-secondary); padding: 0 8px; }
.rec-line .warn { color: #f5a524; font-weight: 650; }
.sfield.plfilter { margin-top: 10px; }
.pl-rows .trk .thumb { width: 38px; height: 38px; border-radius: 6px; overflow: hidden; flex: none; background: rgba(255, 255, 255, 0.06); display: grid; place-items: center; color: var(--text-muted); }
.pl-rows .trk .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pl-rows .trk .n.stamped { width: auto; min-width: 22px; font-size: 11.5px; }
.pl-rows .trk.unresolved .t, .pl-rows .trk.missing .t { color: var(--text-secondary); }
.pl-rows .trk .t .tag { margin-left: 6px; vertical-align: 1px; }
.tag.un { background: rgba(232, 163, 61, 0.16); color: #e8a33d; }
.tag.miss { background: rgba(241, 94, 108, 0.16); color: #f15e6c; }
.tag.seg { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); }
/* the neutral toast: a fact with one action (Undo); the error toast keeps its red */
.toast.notice { display: flex; align-items: center; gap: 12px; background: #1f1f26; color: #fff; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.1); }
.toast.notice .nt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toast.notice .na { flex: none; color: var(--accent-hover); font-weight: 800; font-size: 13px; padding: 4px 8px; margin: -4px -8px -4px 0; border-radius: 6px; }
.toast.notice .na:active { background: rgba(255, 255, 255, 0.1); }
/* the prompt and confirm sheets */
.prompt { padding: 4px var(--pad) 16px; display: flex; flex-direction: column; gap: 12px; }
.prompt .field.area { height: auto; min-height: 84px; padding: 12px 14px; border-radius: 14px; resize: none; line-height: 1.4; font-family: inherit; }
.prompt-acts { display: flex; gap: 8px; }
.prompt-acts .btn { flex: 1; }
.btn.btn-fill.danger { background: #f15e6c; color: #fff; }
.btn.btn-fill.danger:active { background: #d94a58; }
/* ── the import doors and the review ─────────────────────────────────── */
.plbtns { display: flex; gap: 8px; padding: 0 var(--pad) 12px; }
.plbtns .newpl { flex: 1; justify-content: center; }
.imp-head { padding: 4px var(--pad) 10px; display: flex; flex-direction: column; gap: 8px; }
.imp-src { font-size: 14.5px; font-weight: 700; }
.imp-counts { font-size: 13px; color: var(--text-muted); }
.imp-counts b { font-weight: 800; }
.imp-counts .ok { color: #3ddc84; }
.imp-counts .st { color: var(--accent-hover); }
.imp-counts .un { color: #e8a33d; }
.imp-warn { font-size: 12.5px; line-height: 1.5; color: #e8a33d; padding: 8px 12px; border-radius: 10px; background: rgba(232, 163, 61, 0.1); }
.imp-rows .trk { min-height: 44px; }
.imp-rows .trk .tag { flex: none; }
.imp-rows .trk.excluded { opacity: 0.4; }
.tag.ok { background: rgba(61, 220, 132, 0.16); color: #3ddc84; }
.tag.st { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent-hover); }
.tag.dim { opacity: 0.7; }
.imp-form { margin: 10px var(--pad) 0; }
.imp-foot { display: flex; gap: 8px; padding: 14px var(--pad) 4px; }
.imp-foot .btn { flex: 1; }
.imp-wait { margin: 40px var(--pad) 0; padding: 26px 18px; border-radius: var(--r-card); background: var(--surface); box-shadow: var(--ring); display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; font-size: 13px; color: var(--text-secondary); }
.imp-wait b { color: #fff; font-size: 15px; }
.imp-wait .ring { width: 26px; height: 26px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
@keyframes imp-spin { to { transform: rotate(360deg); } }
/* ── You → Sources: a window per mode, the priority inside it ─────────── */
.pref-head { padding: 12px 14px 2px; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--text-muted); }
.pref { padding: 8px 0 12px; }
.pref + .pref { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); }
.pref-l { padding: 0 14px 8px; }
.pref-l .t { display: block; font-size: 14.5px; font-weight: 650; }
.pref-l .m { display: block; font-size: 12px; color: var(--text-muted); margin-top: 1px; }
.pref .chips { padding: 0 14px; }
/* ── a playlist's bulk run: the desktop's bulkline, under the actions ── */
.bulkline { display: inline-flex; align-items: center; gap: 8px; margin: 2px auto 4px; padding: 7px 8px 7px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); font-size: 12.5px; color: var(--text-secondary); max-width: 100%; text-align: left; }
.bulkline > span:not(.ring) { min-width: 0; }
.bulkline .ring { width: 14px; height: 14px; flex: none; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.15); border-top-color: var(--accent); animation: imp-spin 0.9s linear infinite; }
.bulkline .bulkx { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; color: var(--text-muted); }
.bulkline .bulkx:active { background: rgba(255, 255, 255, 0.12); }
+211 -5
View File
@@ -73,7 +73,7 @@ const PROBE = `(function () {
accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(),
amb: [].map.call(document.querySelectorAll('.app-bg .amb'), function (a) { return a.className + ':' + (a.style.backgroundImage ? 'img' : '-'); }).join(' '),
playing: (document.querySelector('.mini .t') || {}).textContent || '',
toast: (document.querySelector('.toast') || {}).textContent || '',
toast: (document.querySelector('.toast:not(.notice)') || {}).textContent || '',
live: (document.querySelector('.mini .m.live') || {}).textContent || '',
peers: document.querySelectorAll('.peer').length,
songs: document.querySelectorAll('.cat-song').length,
@@ -98,6 +98,30 @@ const PROBE = `(function () {
news: document.querySelectorAll('.row.news').length,
secs: [].map.call(document.querySelectorAll('.screen .sec h2'), function (h) { return h.firstChild && h.firstChild.textContent ? h.firstChild.textContent.trim() : ''; }).join('|'),
xlive: (document.querySelector('.screen .sec h2') || {}).textContent || '',
plcards: document.querySelectorAll('.plgrid .release').length,
plnames: [].map.call(document.querySelectorAll('.plgrid .release .title'), function (t) { return t.textContent.trim(); }).join('|'),
plpinned: !!document.querySelector('.plsec.pinned'),
plrows: document.querySelectorAll('.pl-rows .trk').length,
pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '',
plfilter: !!document.querySelector('.plfilter input'),
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
imp: document.querySelectorAll('.imp-rows .trk').length,
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '',
impWait: !!document.querySelector('.imp-wait'),
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
rowArt: document.querySelectorAll('.screen .row .art img').length,
notice: (document.querySelector('.toast.notice') || {}).textContent || '',
promptVal: (document.querySelector('.halfsheet .prompt .field') || {}).value || '',
/* the history mirror: the entry's depth against the layers the app says the back gesture can peel */
hd: (history.state && history.state.d != null) ? history.state.d : -1,
hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(),
cardImgs: document.querySelectorAll('.rail .acard img').length,
cardPhs: document.querySelectorAll('.rail .acard svg').length,
};
@@ -170,9 +194,75 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
{ do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 },
/* Playlists: the shelf (pinned first), a page holding every row kind,
a local row playing, the stream row through the resolver, the
filter, a removal and its undo, the page's sheet — unpin, rename —
a new playlist from the shelf, a record's tracks added to it from
the record's sheet, and its deletion */
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
{ do: `__click('.plgrid .release', 'Late night')`, name: "playlist", wait: 1200 },
{ do: `document.querySelectorAll('.pl-rows .trk')[1].click()`, name: "playlist-playing", wait: 1200 },
{ do: `__click('.pl-rows .trk', 'One More Time')`, name: "playlist-stream", wait: 5000 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 },
/* the bulk actions from the page's sheet: Add to library files the
streamed row (its cache is complete); Download all finds it cached too */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Add to library')`, name: "playlist-bulk-cached", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Rename')`, name: "playlist-renamed", wait: 1000 },
{ do: `history.back()`, name: "playlists-again", wait: 900 },
{ do: `__click('.newpl', 'New playlist')`, name: "playlist-new-prompt", wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1200 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 700 },
{ do: `__click('.halfsheet .row', 'New Playlist')`, name: "playlist-added", wait: 1200 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 700 },
{ do: `__click('.plgrid .release', 'New Playlist')`, name: "playlist-filled", wait: 1200 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, name: "playlist-delete-confirm", wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1200 },
/* the import doors: a pasted tracklist reviewed and created with the
unresolved row kept out; then a link — its stream rows verified
against the catalog while the review is up — created as it came */
{ do: `__click('.newpl', 'Import')`, name: "import-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, name: "import-paste-prompt", wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nAphex Twin - Track 1\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 2200 },
{ do: `__click('.imp-form .row', 'Keep unresolved')`, name: "import-keep-off", wait: 500 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 1500 },
{ do: `history.back()`, wait: 900 },
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Playlist from a link')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-link-wait", wait: 500 },
{ name: "import-link-review", wait: 3400 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 },
{ do: `history.back()`, name: "playlists-after-import", wait: 900 },
/* a share from another app: the plugin hands the text over when the
app comes to the front, and it lands in the import */
{ do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 },
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 500 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
/* the page overflow → the sleep timer */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
@@ -183,7 +273,10 @@ const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
community: (i) => i.title.startsWith("Community"),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow,
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
"you-norm-off": (i) => i.norm === "off",
"you-norm-on": (i) => i.norm === "on",
"you-lossy": (i) => i.srcMax === "Lossy only",
"sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"),
home: (i) => /^(Good |Still)/.test(i.title),
@@ -220,6 +313,41 @@ const MOCK_EXPECT = {
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3,
playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter,
"playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast,
"playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
"playlist-filter": (i) => i.plrows === 1,
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice),
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
"playlist-renamed": (i) => i.title.startsWith("Late night drive II"),
"playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames),
"playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal),
"playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0,
"playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText),
"playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice),
"playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5,
"playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames),
"import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText),
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "",
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn),
"import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn),
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2,
/* the wait card is transient: the still can take longer than the mock's fetch */
"import-link-wait": (i) => i.title.startsWith("Import") && (i.impWait || i.imp === 5),
"import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn),
"import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5,
"playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,
"share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5,
"share-cancel": (i) => i.title.startsWith("Your library"),
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
@@ -253,6 +381,48 @@ const PHONE_STEPS = [
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
/* the artist page: the library's shell at once, the catalog dissolving in */
/* button.row: the page header is a .row too, and it is a div */
/* playlists on the real core: one made here, a record's tracks added
from the record's sheet, a row played, the playlist deleted again —
the phone's library is left as it was */
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
{ do: `__click('.newpl', 'New playlist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Walk test'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1500 },
{ do: `history.back()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
{ do: `(function () { var all = document.querySelectorAll('.grid2 .release'); var c = null; for (var i = 0; i < all.length; i++) { if (!${albumText} || all[i].textContent.indexOf(${albumText}) >= 0) { c = all[i]; break; } } if (!c) throw new Error('no album card'); c.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 900 },
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 800 },
{ do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
/* the paste door on the real core: two lines that resolve to a record
in the library, reviewed, created, deleted again */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
/* the share sheet: a Spotify playlist link sent by "another app" (an
intent on the plugin's activity) lands in the import — the review is
left without creating anything */
{ adb: ["shell", "am", "start", "-a", "android.intent.action.SEND", "-t", "text/plain", "--es", "android.intent.extra.TEXT", "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "-n", "com.soulstream.app/com.soulstream.share.ShareActivity"], name: "share-import", wait: 9000 },
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
/* You: the settings save for real (camelCase args) — the switch and a
chip, each read back from the config, each restored */
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 900 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 900 },
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
@@ -276,6 +446,24 @@ const PHONE_EXPECT = {
"hold-sheet": (i) => i.half.includes("up"),
tracks: (i) => i.title.startsWith("Your library"),
"artist-cold": (i) => i.screen.includes("has-hero"),
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
"you-norm-off": (i) => i.norm === "off" && !i.toast,
"you-norm-on": (i) => i.norm === "on" && !i.toast,
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,
"you-nolimit": (i) => i.srcMax === "No limit" && !i.toast,
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
"playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast,
"playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,
"playlist-playing": (i) => i.mini && !!i.playing && !i.toast,
"playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 2 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2 && !i.toast,
"import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
end: (i) => /^(Good |Still)/.test(i.title),
@@ -349,7 +537,8 @@ async function main() {
} else {
if (!CHROME) throw new Error("Chrome not found");
console.log("building dist-smoke/ …");
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./"], { cwd: ROOT, shell: true, encoding: "utf8" });
/* unminified, so a warning's frames name the function */
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./", "--minify", "false"], { cwd: ROOT, shell: true, encoding: "utf8" });
if (b.status !== 0) { console.error(b.stdout, b.stderr); throw new Error("vite build failed"); }
/* a profile per run: a Chrome left behind by an interrupted run keeps
its crashpad file open, and removing that profile throws EBUSY */
@@ -378,7 +567,10 @@ async function main() {
await cdp.send("Log.enable");
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
cdp.on["Runtime.consoleAPICalled"] = (p) => {
if (p.type === "error" || p.type === "warning") problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" "));
if (p.type !== "error" && p.type !== "warning") return;
const frames = (p.stackTrace?.callFrames || []).slice(0, 5)
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
};
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
if (!PHONE) {
@@ -388,6 +580,10 @@ async function main() {
await sleep(600);
}
for (const s of steps) {
/* a phone step can be an adb command — an intent, a key */
if (s.adb) {
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
}
if (s.do) {
try {
/* the helpers live in the page; a reload step wipes them */
@@ -415,8 +611,18 @@ async function main() {
adb("shell", "am", "force-stop", "com.soulstream.app");
await sleep(800);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
await sleep(6000);
/* a relaunch restores a PAUSED track, which starts the media service
as a foreground service that must promote itself at once — on
2026-09-13 it did not, and Android killed the app ten seconds
later (a Save died with it). Wait past that window BEFORE
resuming, and the pid must be the same one afterwards. */
adb("logcat", "-c");
await sleep(13000);
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
const alive = !!pid2 && !fatal;
if (!alive) problems.push("EXPECT survives-paused-restore " + JSON.stringify({ pid: pid2, fatal }));
console.log((alive ? " ok " : " FAIL ") + "paused-restore".padEnd(16) + " " + JSON.stringify({ pid: pid2 || null, fatal }));
adb("forward", "--remove-all");
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
cdp = await CDP.attach(PORT);