feat(web+android): Teilen per Link
Ein Song oder Album laesst sich per https://<host>/?song=<id> an jemanden auf derselben Instanz schicken. Der Link traegt nur die Navidrome-Id, keine Zugangsdaten — der Empfaenger sieht den Inhalt mit seinem eigenen Konto. Web: Teilen ueber navigator.share() mit Rueckfall auf die Zwischenablage. Beim Start wird ?song= / ?album= aufgeloest (nach der Anmeldung, vorher fehlen die Zugangsdaten), das Album geoeffnet, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. Das Manifest bekommt launch_handler=navigate-existing, damit der Link in einem bereits offenen PWA-Fenster landet statt in einem neuen. Android: Teilen ueber ACTION_SEND, Empfang ueber einen App-Link-Intent-Filter. Der Host ist eine Build-Eigenschaft (-PnovasonicHost=…, verifiziert am zusammengefuehrten Manifest), die Web-Adresse fuer ausgehende Links eine neue Einstellung. Weil der NavController in der Compose-Hierarchie lebt und die Activity nicht selbst navigieren kann, reicht DeepLinkState den Link durch; onNewIntent ist mitbedacht, sonst passierte beim zweiten Link nichts. assetlinks.json liegt als Vorlage in public/ und wird vom Frontend-Image ausgeliefert (im Build verifiziert); ohne den Release-Fingerabdruck oeffnet der Link die Web-App im Browser statt die App. Anleitung: docs/share-links.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c99778567
commit
430ecdc737
16 files changed
+288
-3
No files matched your search
@@ -111,6 +111,12 @@ async function startApp() {
|
||||
} else {
|
||||
navigate('home');
|
||||
}
|
||||
// Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine
|
||||
// Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die
|
||||
// wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war.
|
||||
import('./ui/share.js')
|
||||
.then(m => m.handleShareParam(navigate, playQueue))
|
||||
.catch(() => {});
|
||||
initNotifBell();
|
||||
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
|
||||
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
|
||||
|
||||
@@ -31,6 +31,7 @@ export function showCtxMenu(e, song, list, idx) {
|
||||
}},
|
||||
{ sep: true },
|
||||
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
|
||||
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
|
||||
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
|
||||
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { S } from '../state.js';
|
||||
import { toast } from '../utils.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// ─── TEILEN PER LINK ───
|
||||
// Ein Link traegt NUR eine Navidrome-Id, keine Zugangsdaten: der Empfaenger sieht den Inhalt mit
|
||||
// seinen eigenen Anmeldedaten auf derselben Instanz. Als Query-Parameter (?song=…) statt als Pfad,
|
||||
// damit der Webserver keine SPA-Umschreibung braucht — die App liest ihn beim Start selbst aus.
|
||||
export function buildShareLink(type, id) {
|
||||
const base = `${location.origin}${location.pathname.replace(/index\.html$/, '')}`;
|
||||
return `${base}?${type}=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilt einen Link ueber das System (Handy: Teilen-Dialog) und faellt sonst auf die Zwischenablage
|
||||
* zurueck. navigator.share gibt es nur in sicheren Kontexten und nicht auf jedem Desktop-Browser.
|
||||
*/
|
||||
export async function shareLink(type, id, title, subtitle) {
|
||||
const url = buildShareLink(type, id);
|
||||
const text = subtitle ? `${title} — ${subtitle}` : title;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, text, url });
|
||||
return;
|
||||
} catch (e) {
|
||||
// AbortError = der Nutzer hat den Dialog geschlossen; das ist kein Fehler.
|
||||
if (e?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast('🔗 Link kopiert');
|
||||
} catch {
|
||||
// Zwischenablage kann blockiert sein (kein HTTPS, kein Nutzergesten-Kontext) — dann zeigen wir
|
||||
// den Link, damit er wenigstens von Hand kopiert werden kann.
|
||||
toast('🔗 ' + url);
|
||||
}
|
||||
}
|
||||
|
||||
export const shareSong = (s) => shareLink('song', s.id, s.title || 'Song', s.artist || '');
|
||||
export const shareAlbum = (a) => shareLink('album', a.id, a.name || 'Album', a.artist || '');
|
||||
|
||||
/**
|
||||
* Loest einen beim Start uebergebenen Link auf: Song abspielen bzw. Album oeffnen. Wird NACH der
|
||||
* Anmeldung aufgerufen — ohne gueltige Zugangsdaten gibt es nichts abzuspielen. Der Parameter wird
|
||||
* anschliessend aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt.
|
||||
*/
|
||||
export async function handleShareParam(navigate, playQueue) {
|
||||
const p = new URLSearchParams(location.search);
|
||||
const songId = p.get('song');
|
||||
const albumId = p.get('album');
|
||||
if (!songId && !albumId) return false;
|
||||
|
||||
const sauber = () => {
|
||||
try { history.replaceState(history.state, '', location.pathname); } catch {}
|
||||
};
|
||||
|
||||
try {
|
||||
if (songId) {
|
||||
const r = await api('getSong', `&id=${encodeURIComponent(songId)}`);
|
||||
const song = r.song;
|
||||
if (!song) { toast('Song nicht gefunden'); sauber(); return false; }
|
||||
if (song.albumId) {
|
||||
navigate('album-detail', {
|
||||
id: song.albumId, name: song.album || '', artist: song.artist || '',
|
||||
artistId: song.artistId || '', coverArt: song.coverArt || '',
|
||||
});
|
||||
}
|
||||
playQueue([song], 0);
|
||||
toast(`▶ ${song.title}`);
|
||||
} else {
|
||||
const r = await api('getAlbum', `&id=${encodeURIComponent(albumId)}`);
|
||||
const al = r.album;
|
||||
if (!al) { toast('Album nicht gefunden'); sauber(); return false; }
|
||||
navigate('album-detail', {
|
||||
id: al.id, name: al.name || '', artist: al.artist || '',
|
||||
artistId: al.artistId || '', coverArt: al.coverArt || '',
|
||||
});
|
||||
}
|
||||
sauber();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast('Link konnte nicht geöffnet werden: ' + e.message);
|
||||
sauber();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user