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:
1 parent
bb828ff2dc
commit
6e39dc43f3
38 files changed
+2383
-88
No files matched your search
+101
-5
@@ -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
|
dimmed cards that open the catalog record page, the totals remembered in
|
||||||
`library.gaps.v1` so the line says its number before the lookup.
|
`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
|
## Background playback and the lock-screen card
|
||||||
|
|
||||||
The service is started with `startForegroundService`, so it promotes itself
|
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
|
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
|
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 →
|
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
|
a catalog record, its Save and the Transfers burst → Gaps → the playlist
|
||||||
step into `tests/mobile/out/`, and fails on any exception or console error.
|
shelf and a page with every row kind (a local row, the stream row through
|
||||||
Two toasts are expected there and filtered: without Tauri, `listen` and
|
the resolver, the filter, a removal and its undo, unpin, rename, a new
|
||||||
`invoke` do not exist.
|
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
|
exposes DevTools on `localabstract:webview_devtools_remote_<pid>`, `adb
|
||||||
forward` puts it on a local port, and the steps run against the real library
|
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
|
— real playback, real covers, the colour following the record — with the
|
||||||
|
|||||||
Generated
+11
@@ -195,6 +195,7 @@ dependencies = [
|
|||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-media-session",
|
"tauri-plugin-media-session",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-share",
|
||||||
"tauri-plugin-storage",
|
"tauri-plugin-storage",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -5842,6 +5843,16 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-share"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-storage"
|
name = "tauri-plugin-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
"crates/persona-state",
|
"crates/persona-state",
|
||||||
"crates/tauri-plugin-media-session",
|
"crates/tauri-plugin-media-session",
|
||||||
"crates/tauri-plugin-storage",
|
"crates/tauri-plugin-storage",
|
||||||
|
"crates/tauri-plugin-share",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ audio-formats = { path = "../crates/audio-formats" }
|
|||||||
# platform; on the desktop its commands answer "not here".
|
# platform; on the desktop its commands answer "not here".
|
||||||
tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" }
|
tauri-plugin-media-session = { path = "../crates/tauri-plugin-media-session" }
|
||||||
tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" }
|
tauri-plugin-storage = { path = "../crates/tauri-plugin-storage" }
|
||||||
|
tauri-plugin-share = { path = "../crates/tauri-plugin-share" }
|
||||||
semver = "1"
|
semver = "1"
|
||||||
tauri-plugin-opener = "2.5.5"
|
tauri-plugin-opener = "2.5.5"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"media-session:default",
|
"media-session:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"storage:default"
|
"storage:default",
|
||||||
|
"share:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -83,6 +83,8 @@ pub fn run() {
|
|||||||
app.handle().plugin(tauri_plugin_opener::init())?;
|
app.handle().plugin(tauri_plugin_opener::init())?;
|
||||||
// The library folder on the phone: the picker and all-files access.
|
// The library folder on the phone: the picker and all-files access.
|
||||||
app.handle().plugin(tauri_plugin_storage::init())?;
|
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)]
|
#[cfg(desktop)]
|
||||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
// Must run before anything touches the app-data dir.
|
// Must run before anything touches the app-data dir.
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ export const rescanLibrary = () => invoke("rescan_library");
|
|||||||
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
|
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
|
||||||
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
|
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
|
||||||
export const requestAllFilesAccess = () => invoke("plugin:storage|request_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
|
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||||
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
||||||
// second batch's indices continuing, since index 0 means "track 1".
|
// second batch's indices continuing, since index 0 means "track 1".
|
||||||
|
|||||||
+253
-7
@@ -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 = (() => {
|
const enabled = (() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("mockLibrary") === "1";
|
return localStorage.getItem("mockLibrary") === "1";
|
||||||
@@ -95,6 +288,7 @@ const enabled = (() => {
|
|||||||
/** Returns a Promise for mocked commands, or undefined to pass through. */
|
/** Returns a Promise for mocked commands, or undefined to pass through. */
|
||||||
export function mockInvoke(cmd, args) {
|
export function mockInvoke(cmd, args) {
|
||||||
if (!enabled) return undefined;
|
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_albums") return Promise.resolve(albums);
|
||||||
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
||||||
if (cmd === "recent_albums") return Promise.resolve([]);
|
if (cmd === "recent_albums") return Promise.resolve([]);
|
||||||
@@ -105,7 +299,7 @@ export function mockInvoke(cmd, args) {
|
|||||||
const num = m ? +m[1] : 0;
|
const num = m ? +m[1] : 0;
|
||||||
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
|
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
|
// The track index, as the library crate shapes it (TrackIndexRow) — the
|
||||||
// phone's Tracks tab and its library search read these.
|
// phone's Tracks tab and its library search read these.
|
||||||
if (cmd === "library_track_index" || cmd === "library_track_search") {
|
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)
|
? 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 ───────────────────────────────────────────────────
|
/* ── 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 === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
|
||||||
if (cmd === "preload_stream") {
|
if (cmd === "preload_stream") {
|
||||||
const path = args?.path;
|
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); }); }
|
if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); }
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
|
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
|
||||||
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
|
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
|
||||||
if (cmd === "get_settings") return Promise.resolve({ download_dir: "C:\\mock\\Library" });
|
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") return Promise.resolve();
|
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|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|all_files_access") return Promise.resolve({ granted: true });
|
||||||
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = 0;
|
let token = 0;
|
||||||
let downloadSeq = 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();
|
const preloading = new Set();
|
||||||
let xfer = null; // the one fake transfer: { received, total, timer }
|
let xfer = null; // the one fake transfer: { received, total, timer }
|
||||||
let posTimer = null; // the fake engine's position ticks
|
let posTimer = null; // the fake engine's position ticks
|
||||||
@@ -328,17 +542,38 @@ function mockNet(cmd, args) {
|
|||||||
return Promise.resolve(t);
|
return Promise.resolve(t);
|
||||||
}
|
}
|
||||||
if (cmd === "stop_search") return Promise.resolve();
|
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 (cmd === "play_stream") {
|
||||||
if (xfer) clearInterval(xfer.timer);
|
if (xfer) clearInterval(xfer.timer);
|
||||||
clearInterval(posTimer);
|
clearInterval(posTimer);
|
||||||
const total = args?.size || 30e6;
|
const total = args?.size || 30e6;
|
||||||
const duration = args?.duration || 240;
|
const duration = args?.duration || 240;
|
||||||
xfer = { received: 0, total, timer: null, path: args?.path };
|
xfer = { received: 0, total, timer: null, path: args?.path };
|
||||||
|
cacheSizes.set(args?.path, total);
|
||||||
xfer.timer = setInterval(() => {
|
xfer.timer = setInterval(() => {
|
||||||
xfer.received = Math.min(total, xfer.received + total / 30);
|
xfer.received = Math.min(total, xfer.received + total / 30);
|
||||||
const done = xfer.received >= total;
|
const done = xfer.received >= total;
|
||||||
mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null });
|
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);
|
}, 400);
|
||||||
let secs = 0;
|
let secs = 0;
|
||||||
later(1200, () => {
|
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)));
|
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 });
|
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 === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null);
|
||||||
if (cmd === "record_play") return Promise.resolve(1);
|
if (cmd === "record_play") return Promise.resolve(1);
|
||||||
if (cmd === "add_play_seconds") return Promise.resolve();
|
if (cmd === "add_play_seconds") return Promise.resolve();
|
||||||
|
|||||||
+199
-1
@@ -9,8 +9,10 @@
|
|||||||
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
|
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 { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
|
||||||
import { streamSong, saveSong } from "./resolve.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 { 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";
|
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
|
||||||
|
|
||||||
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
|
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
const t = top();
|
const t = top();
|
||||||
if (t.n === "album") return "album-more";
|
if (t.n === "album") return "album-more";
|
||||||
if (t.n === "library") return "library-more";
|
if (t.n === "library") return "library-more";
|
||||||
|
if (t.n === "playlist") return "playlist-more";
|
||||||
return "home-more";
|
return "home-more";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,6 +54,94 @@
|
|||||||
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
||||||
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
|
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
|
/* a catalog track (no file on this phone): the resolver plays it, Save
|
||||||
fetches it — the desktop's row buttons, on the sheet */
|
fetches it — the desktop's row buttons, on the sheet */
|
||||||
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
|
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
|
||||||
@@ -100,6 +191,7 @@
|
|||||||
{#if isCatalog(track)}
|
{#if isCatalog(track)}
|
||||||
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
|
{@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(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>
|
<div class="sheet-hair"></div>
|
||||||
{#if album?.catalogId != null}
|
{#if album?.catalogId != null}
|
||||||
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
|
{@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}
|
{:else}
|
||||||
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
|
{@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("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>
|
<div class="sheet-hair"></div>
|
||||||
{#if album?.id != null && !onThisAlbum(album.id)}
|
{#if album?.id != null && !onThisAlbum(album.id)}
|
||||||
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
|
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
|
||||||
@@ -137,6 +230,9 @@
|
|||||||
<div class="sheet-rows">
|
<div class="sheet-rows">
|
||||||
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
|
{@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 })))))}
|
{@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>
|
<div class="sheet-hair"></div>
|
||||||
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
|
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
|
||||||
</div>
|
</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>
|
<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}
|
{#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>
|
</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"}
|
{:else if kind === "librarysort"}
|
||||||
<div class="sheet-title">Sort & view</div>
|
<div class="sheet-title">Sort & view</div>
|
||||||
<div class="sheet-sec">Order</div>
|
<div class="sheet-sec">Order</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import Artist from "./screens/Artist.svelte";
|
import Artist from "./screens/Artist.svelte";
|
||||||
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
|
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
|
||||||
import Playlist from "./screens/Playlist.svelte";
|
import Playlist from "./screens/Playlist.svelte";
|
||||||
|
import Import from "./screens/Import.svelte";
|
||||||
import Community from "./screens/Community.svelte";
|
import Community from "./screens/Community.svelte";
|
||||||
import Room from "./screens/Room.svelte";
|
import Room from "./screens/Room.svelte";
|
||||||
import Thread from "./screens/Thread.svelte";
|
import Thread from "./screens/Thread.svelte";
|
||||||
@@ -40,6 +41,10 @@
|
|||||||
import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js";
|
import { slsk, bindSlsk, connectStored } from "./slsk.svelte.js";
|
||||||
import { net, bindNet } from "./net.svelte.js";
|
import { net, bindNet } from "./net.svelte.js";
|
||||||
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.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 * as sources from "../lib/sources.js";
|
||||||
import { greeting, drag } from "./util.js";
|
import { greeting, drag } from "./util.js";
|
||||||
|
|
||||||
@@ -62,6 +67,7 @@
|
|||||||
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
|
case "thread": return { title: e.c.username, kicker: "Chat", flush: true };
|
||||||
case "you": return { title: "You", kicker: "You" };
|
case "you": return { title: "You", kicker: "You" };
|
||||||
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
||||||
|
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
||||||
case "album": {
|
case "album": {
|
||||||
const a = albumById(e.c.id);
|
const a = albumById(e.c.id);
|
||||||
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
|
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
case "playlist": {
|
case "playlist": {
|
||||||
const p = lib.playlists.find((x) => x.id === e.c.id);
|
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: "" };
|
default: return { title: e.n, kicker: "" };
|
||||||
}
|
}
|
||||||
@@ -174,9 +180,11 @@
|
|||||||
watchLibrary();
|
watchLibrary();
|
||||||
bindTransfers();
|
bindTransfers();
|
||||||
bindHome();
|
bindHome();
|
||||||
|
bindImporter();
|
||||||
|
bindShare();
|
||||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||||
tests/mobile/smoke.mjs drives the real app through it */
|
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(); };
|
return () => { if (unlisten) unlisten(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -214,6 +222,8 @@
|
|||||||
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
|
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
|
||||||
{:else if entry.n === "playlist"}
|
{:else if entry.n === "playlist"}
|
||||||
<Playlist id={entry.c.id} />
|
<Playlist id={entry.c.id} />
|
||||||
|
{:else if entry.n === "import"}
|
||||||
|
<Import />
|
||||||
{:else if entry.n === "community"}
|
{:else if entry.n === "community"}
|
||||||
<Community {entry} {screenEl} />
|
<Community {entry} {screenEl} />
|
||||||
{:else if entry.n === "room"}
|
{:else if entry.n === "room"}
|
||||||
@@ -279,4 +289,10 @@
|
|||||||
{#if player.error || lib.error}
|
{#if player.error || lib.error}
|
||||||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
|
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
@@ -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}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
/* `path` is a library cover (through the thumbnail cache); `url` a
|
/* `path` is a library cover (through the thumbnail cache); `url` a
|
||||||
catalog cover, shown straight from the network */
|
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] : ""));
|
const src = $derived(url || (path ? art[path] : ""));
|
||||||
function hold(e) {
|
function hold(e) {
|
||||||
if (!onmore) return;
|
if (!onmore) return;
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
<button class="release" class:w2 {onclick} oncontextmenu={hold} use:visible={path}>
|
<button class="release" class:w2 {onclick} oncontextmenu={hold} use:visible={path}>
|
||||||
<span class="cover-wrap">
|
<span class="cover-wrap">
|
||||||
{#if badge}<span class="ed-badge">{badge}</span>{/if}
|
{#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>
|
||||||
<span class="title">{title}</span>
|
<span class="title">{title}</span>
|
||||||
<span class="sub">{sub}</span>
|
<span class="sub">{sub}</span>
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export function visible(node, path) {
|
|||||||
},
|
},
|
||||||
{ rootMargin: "320px" },
|
{ 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);
|
arm(path);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,8 @@ export async function watchLibrary() {
|
|||||||
await refreshRecents();
|
await refreshRecents();
|
||||||
}, 600);
|
}, 600);
|
||||||
});
|
});
|
||||||
|
/* a playlist mutation (the desktop's commands emit it) re-lists them */
|
||||||
|
await listen("soulseek:playlists-changed", () => { void refreshPlaylists(); });
|
||||||
} catch { /* not a phone */ }
|
} catch { /* not a phone */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,3 +123,7 @@ export async function loadLibrary() {
|
|||||||
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
|
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
|
||||||
lib.loaded = true;
|
lib.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshPlaylists() {
|
||||||
|
try { lib.playlists = (await api.listPlaylists()) || []; } catch { /* keep what we have */ }
|
||||||
|
}
|
||||||
@@ -58,9 +58,22 @@ function backables() {
|
|||||||
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
|
+ (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() {
|
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();
|
const want = backables();
|
||||||
while (depth < want) {
|
while (depth < want) {
|
||||||
depth++;
|
depth++;
|
||||||
@@ -78,7 +91,7 @@ export function bootNav() {
|
|||||||
booted = true;
|
booted = true;
|
||||||
try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ }
|
try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ }
|
||||||
window.addEventListener("popstate", () => {
|
window.addEventListener("popstate", () => {
|
||||||
if (suppress > 0) { suppress--; return; }
|
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
|
||||||
depth = Math.max(0, depth - 1);
|
depth = Math.max(0, depth - 1);
|
||||||
if (nav.sheet) nav.sheet = false;
|
if (nav.sheet) nav.sheet = false;
|
||||||
else if (nav.half) nav.half = null;
|
else if (nav.half) nav.half = null;
|
||||||
@@ -143,6 +156,17 @@ export function patch(c) {
|
|||||||
Object.assign(top().c, 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) {
|
export function openSheet(fromEl = null) {
|
||||||
if (nav.sheet) return;
|
if (nav.sheet) return;
|
||||||
let rect = null;
|
let rect = null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ async function startEntry(e, startSecs = null) {
|
|||||||
armedPath = null;
|
armedPath = null;
|
||||||
player.track = e.t;
|
player.track = e.t;
|
||||||
player.album = e.album;
|
player.album = e.album;
|
||||||
player.position = startSecs || 0;
|
player.position = startSecs ?? e.t.startSecs ?? 0;
|
||||||
player.duration = e.t.duration_secs ?? null;
|
player.duration = e.t.duration_secs ?? null;
|
||||||
player.status = "buffering";
|
player.status = "buffering";
|
||||||
player.needsResource = false;
|
player.needsResource = false;
|
||||||
@@ -162,7 +162,7 @@ async function startEntry(e, startSecs = null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
|
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
|
||||||
return;
|
return;
|
||||||
@@ -183,6 +183,7 @@ async function startEntry(e, startSecs = null) {
|
|||||||
one — 12s for the first peer (an indirect handshake honestly takes ~10),
|
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. */
|
8s for the rest — and when they are spent the queue moves on. */
|
||||||
let watchdog = null;
|
let watchdog = null;
|
||||||
|
let endFired = 0; /* the generation whose set-slice end bound has fired */
|
||||||
function armWatchdog(g, e) {
|
function armWatchdog(g, e) {
|
||||||
clearTimeout(watchdog);
|
clearTimeout(watchdog);
|
||||||
const first = !e.t.tried;
|
const first = !e.t.tried;
|
||||||
@@ -383,7 +384,9 @@ export function playEntries(entries, at = 0, from = "") {
|
|||||||
player.queue = entries;
|
player.queue = entries;
|
||||||
player.qi = at;
|
player.qi = at;
|
||||||
player.from = from;
|
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) {
|
export function playAlbum(album, tracks, at = 0) {
|
||||||
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
|
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
|
||||||
@@ -577,6 +580,11 @@ export async function bindEvents() {
|
|||||||
if (typeof p.secs === "number") {
|
if (typeof p.secs === "number") {
|
||||||
player.position = p.secs;
|
player.position = p.secs;
|
||||||
listenTick(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;
|
if (p.duration != null) player.duration = p.duration;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import Release from "../Release.svelte";
|
import Release from "../Release.svelte";
|
||||||
import Cover from "../Cover.svelte";
|
import Cover from "../Cover.svelte";
|
||||||
import Tracks from "./Tracks.svelte";
|
import Tracks from "./Tracks.svelte";
|
||||||
|
import PlaylistShelf from "../PlaylistShelf.svelte";
|
||||||
import * as api from "../../lib/api.js";
|
import * as api from "../../lib/api.js";
|
||||||
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
|
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
|
||||||
import { push, go, patch, openHalf } from "../nav.svelte.js";
|
import { push, go, patch, openHalf } from "../nav.svelte.js";
|
||||||
@@ -136,19 +137,7 @@
|
|||||||
{:else if tab === "tracks"}
|
{:else if tab === "tracks"}
|
||||||
<Tracks {entry} />
|
<Tracks {entry} />
|
||||||
{:else if tab === "playlists"}
|
{:else if tab === "playlists"}
|
||||||
{#if !lib.playlists.length}
|
<PlaylistShelf {screenEl} />
|
||||||
<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}
|
|
||||||
{:else if tab === "gaps"}
|
{:else if tab === "gaps"}
|
||||||
{#if !shelves.length}
|
{#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>
|
<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>
|
||||||
|
|||||||
@@ -1,70 +1,137 @@
|
|||||||
<script>
|
<script>
|
||||||
/* A playlist, in the record page's shape. Rows without a local file
|
/* A playlist — the desktop's Playlist.svelte at phone scale, in the
|
||||||
(streams, unresolved imports) keep their place and say so. */
|
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 Icon from "../../components/Icon.svelte";
|
||||||
|
import Cover from "../Cover.svelte";
|
||||||
import * as api from "../../lib/api.js";
|
import * as api from "../../lib/api.js";
|
||||||
import { lib } from "../lib.svelte.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 { 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();
|
let { id } = $props();
|
||||||
|
|
||||||
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
|
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
|
||||||
let items = $state([]);
|
let items = $state([]);
|
||||||
let cover = $state("");
|
let loaded = $state(false);
|
||||||
|
let gen = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const p = pl;
|
const p = pl;
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
api.playlistItems(p.id).then((r) => { items = r || []; }, (e) => { player.error = String(e); });
|
void p.revision;
|
||||||
api.playlistCover(p.id, 360).then((u) => { cover = u || ""; }, () => {});
|
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) {
|
const cover = $derived(pl ? coverFor(pl) : null);
|
||||||
return {
|
let q = $state("");
|
||||||
t: { id: "pl" + x.id, title: x.title, artist: x.artist, path: x.path, duration_secs: x.duration_secs ?? null },
|
const norm = (s) => String(s ?? "").toLowerCase();
|
||||||
album: { id: null, title: x.album || pl.name, artist: x.artist, art_path: x.art_path || null },
|
const visible = $derived.by(() => {
|
||||||
};
|
const k = norm(q.trim());
|
||||||
}
|
if (!k) return items;
|
||||||
function playFrom(i) {
|
return items.filter((i) => norm(i.title).includes(k) || norm(i.artist).includes(k) || norm(i.album).includes(k));
|
||||||
const es = items.slice(i).filter((x) => x.path).map(entryOf);
|
});
|
||||||
if (es.length) playEntries(es, 0, pl.name);
|
/* 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 */
|
||||||
function shuffle() {
|
const stamped = $derived(items.length > 0 && items.every((i) => i.start_ms != null));
|
||||||
const es = shuffled(items.filter((x) => x.path).map(entryOf));
|
const unresolved = $derived(unresolvedCount(items));
|
||||||
if (es.length) { player.shuffle = true; playEntries(es, 0, pl.name); }
|
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>
|
</script>
|
||||||
|
|
||||||
{#if pl}
|
{#if pl}
|
||||||
<div class="rec-head">
|
<div class="rec-head pl">
|
||||||
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={48} />{/if}</div>
|
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={56} />{/if}</div>
|
||||||
<div class="rec-meta">
|
<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-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-desc">{pl.description}</div>{/if}
|
||||||
{#if pl.description}<div class="rec-line" style="margin-top: 8px; color: var(--text-secondary)">{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">
|
<div class="rec-acts">
|
||||||
<button class="iconbtn muted" onclick={shuffle} aria-label="Shuffle"><Icon name="shuffle" size={22} /></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(0)} aria-label="Play"><Icon name="play" size={26} /></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>
|
</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>
|
</div>
|
||||||
<div class="tracks">
|
|
||||||
{#each items as x, i (x.id)}
|
{#if loaded && !items.length}
|
||||||
<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}>
|
<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>
|
||||||
<span class="n">{i + 1}</span>
|
{:else}
|
||||||
<span class="b">
|
{#if items.length}
|
||||||
<span class="t">{x.title}</span>
|
<div class="sfield plfilter">
|
||||||
<span class="m">{x.artist}{x.album ? " · " + x.album : ""}{x.path ? "" : " · not on this phone"}</span>
|
<Icon name="search" size={18} />
|
||||||
</span>
|
<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" />
|
||||||
<span class="d">{mmss(x.duration_secs)}</span>
|
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
|
||||||
</button>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
{#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}
|
{: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}
|
{/if}
|
||||||
|
|
||||||
<div class="endpad"></div>
|
<div class="endpad"></div>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { transfers, active } from "../transfers.svelte.js";
|
import { transfers, active } from "../transfers.svelte.js";
|
||||||
import { player } from "../player.svelte.js";
|
import { player } from "../player.svelte.js";
|
||||||
import { settings, setSetting } from "../prefs.svelte.js";
|
import { settings, setSetting } from "../prefs.svelte.js";
|
||||||
|
import { refreshSourcePrefs } from "../../lib/sources.js";
|
||||||
|
|
||||||
let cfg = $state(null);
|
let cfg = $state(null);
|
||||||
let scanning = $state(false);
|
let scanning = $state(false);
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
|
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
|
||||||
async function toggleNormalization() {
|
async function toggleNormalization() {
|
||||||
const next = !normalization;
|
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
|
/* 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
|
dir (the folder IS the library), rescanned. A folder in shared storage
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
try {
|
try {
|
||||||
const r = await api.pickLibraryFolder();
|
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; }
|
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();
|
cfg = await api.getSettings();
|
||||||
await rescan();
|
await rescan();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -53,6 +54,24 @@
|
|||||||
scanning = false;
|
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 = [
|
const ROWS = [
|
||||||
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
|
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
|
||||||
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
|
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
|
||||||
@@ -110,6 +129,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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="sheet-sec">This phone</div>
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
|
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
|
||||||
@@ -117,8 +162,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stub">
|
<div class="stub">
|
||||||
<b>Playback, storage, the network and the account come next.</b>
|
<b>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.
|
They are the desktop's settings pages, drawn with the phone's components.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="endpad"></div>
|
<div class="endpad"></div>
|
||||||
@@ -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(); });
|
||||||
|
}
|
||||||
@@ -1258,3 +1258,80 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
|||||||
transition-duration: 0.01ms !important;
|
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
@@ -73,7 +73,7 @@ const PROBE = `(function () {
|
|||||||
accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(),
|
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(' '),
|
amb: [].map.call(document.querySelectorAll('.app-bg .amb'), function (a) { return a.className + ':' + (a.style.backgroundImage ? 'img' : '-'); }).join(' '),
|
||||||
playing: (document.querySelector('.mini .t') || {}).textContent || '',
|
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 || '',
|
live: (document.querySelector('.mini .m.live') || {}).textContent || '',
|
||||||
peers: document.querySelectorAll('.peer').length,
|
peers: document.querySelectorAll('.peer').length,
|
||||||
songs: document.querySelectorAll('.cat-song').length,
|
songs: document.querySelectorAll('.cat-song').length,
|
||||||
@@ -98,6 +98,30 @@ const PROBE = `(function () {
|
|||||||
news: document.querySelectorAll('.row.news').length,
|
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('|'),
|
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 || '',
|
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,
|
cardImgs: document.querySelectorAll('.rail .acard img').length,
|
||||||
cardPhs: document.querySelectorAll('.rail .acard svg').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: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
||||||
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
|
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
|
||||||
{ do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 },
|
{ 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: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
||||||
{ do: `__settle()`, wait: 800 },
|
{ do: `__settle()`, wait: 800 },
|
||||||
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
|
{ 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 },
|
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
|
||||||
/* the page overflow → the sleep timer */
|
/* the page overflow → the sleep timer */
|
||||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
|
{ 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"),
|
tracks: (i) => i.title.startsWith("Your library"),
|
||||||
community: (i) => i.title.startsWith("Community"),
|
community: (i) => i.title.startsWith("Community"),
|
||||||
"home-more": (i) => i.half.includes("up"),
|
"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-sheet": (i) => i.half.includes("up"),
|
||||||
"sleep-set": (i) => !i.half.includes("up"),
|
"sleep-set": (i) => !i.half.includes("up"),
|
||||||
home: (i) => /^(Good |Still)/.test(i.title),
|
home: (i) => /^(Good |Still)/.test(i.title),
|
||||||
@@ -220,6 +313,41 @@ const MOCK_EXPECT = {
|
|||||||
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
|
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
|
||||||
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
|
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
|
||||||
"gaps-open": (i) => i.gapCards >= 3,
|
"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-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-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),
|
"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 },
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
||||||
/* the artist page: the library's shell at once, the catalog dissolving in */
|
/* 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 */
|
/* 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')[3].click()`, name: "community", wait: 900 },
|
||||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
|
||||||
{ do: `document.querySelectorAll('.segtabs button')[1].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"),
|
"hold-sheet": (i) => i.half.includes("up"),
|
||||||
tracks: (i) => i.title.startsWith("Your library"),
|
tracks: (i) => i.title.startsWith("Your library"),
|
||||||
"artist-cold": (i) => i.screen.includes("has-hero"),
|
"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,
|
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,
|
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
|
||||||
end: (i) => /^(Good |Still)/.test(i.title),
|
end: (i) => /^(Good |Still)/.test(i.title),
|
||||||
@@ -349,7 +537,8 @@ async function main() {
|
|||||||
} else {
|
} else {
|
||||||
if (!CHROME) throw new Error("Chrome not found");
|
if (!CHROME) throw new Error("Chrome not found");
|
||||||
console.log("building dist-smoke/ …");
|
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"); }
|
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
|
/* a profile per run: a Chrome left behind by an interrupted run keeps
|
||||||
its crashpad file open, and removing that profile throws EBUSY */
|
its crashpad file open, and removing that profile throws EBUSY */
|
||||||
@@ -378,7 +567,10 @@ async function main() {
|
|||||||
await cdp.send("Log.enable");
|
await cdp.send("Log.enable");
|
||||||
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
|
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
|
||||||
cdp.on["Runtime.consoleAPICalled"] = (p) => {
|
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 || "")); };
|
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
|
||||||
if (!PHONE) {
|
if (!PHONE) {
|
||||||
@@ -388,6 +580,10 @@ async function main() {
|
|||||||
await sleep(600);
|
await sleep(600);
|
||||||
}
|
}
|
||||||
for (const s of steps) {
|
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) {
|
if (s.do) {
|
||||||
try {
|
try {
|
||||||
/* the helpers live in the page; a reload step wipes them */
|
/* 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");
|
adb("shell", "am", "force-stop", "com.soulstream.app");
|
||||||
await sleep(800);
|
await sleep(800);
|
||||||
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
|
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 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", "--remove-all");
|
||||||
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
|
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
|
||||||
cdp = await CDP.attach(PORT);
|
cdp = await CDP.attach(PORT);
|
||||||
|
|||||||
Reference in new issue
Block a user