yt-dlp for page links in every Link mode; app icon and Linux launcher entry

yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and
the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are
probed: direct files stay links, pages are resolved for title, uploader,
duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the
managed copy. I pass it on / the server fetches it: the best single-file
format, re-resolved once when its URL expires. Relay names never split
the /r/ path; viewers of server-fetched pages wait for the relay to go
live. No wire change.

Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the
Windows .ico). GTK app id moe.senkochan.watch_together; release builds
install their .desktop file and icons into ~/.local/share so Wayland
shows the icon; the window icon is set for X11. README shows the icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 18:21:22 +03:00
1 parent 9ddd85de90
commit 745717a4a0
40 files changed
+1444 -123

No files matched your search

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
@@ -12,6 +13,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
@@ -132,7 +134,10 @@ final class _FileOrigin extends _RelayOrigin {
final class _LinkOrigin extends _RelayOrigin {
final String url;
const _LinkOrigin(this.url);
/// What the link is (a page goes through [ytDlp]).
final LinkProbe probe;
final YtDlp ytDlp;
const _LinkOrigin(this.url, this.probe, this.ytDlp);
}
/// A Jellyfin item: served with this install's account, else with the
@@ -159,6 +164,8 @@ class SessionCoordinator {
/// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
/// The app's yt-dlp and link probes (tests pass their own).
final AppYtDlp ytDlp;
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
@@ -293,8 +300,9 @@ class SessionCoordinator {
bool _jellyfinListening = false;
SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
: ytDlp = ytDlp ?? AppYtDlp.instance,
jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by
@@ -304,6 +312,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init();
_playerReady = true;
@@ -456,6 +465,7 @@ class SessionCoordinator {
diff.reset();
_opening = true;
try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
@@ -470,8 +480,30 @@ class SessionCoordinator {
}
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
if (uri.host == _wsHost && uri.port == _wsPort) return;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
final path = hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
}
}
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) {
@@ -494,7 +526,7 @@ class SessionCoordinator {
}
String _relayMediaUrlFor(String name, int mediaId) =>
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}';
/// Serves [bytes] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it.
@@ -617,9 +649,19 @@ class SessionCoordinator {
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
} else if (origin is _LinkOrigin) {
await _openMedia(origin.url);
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
_startRelaySource(
r.mediaId, LinkRelayBytes(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true);
} else if (relay != null && !mine) {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
if (relay.fetch && !relay.live) {
// The server is still getting it (yt-dlp takes seconds; mpv
// gives up on a silent request after 5): open once it is live.
_openUrl = null;
_awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
}
} else {
var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
@@ -635,6 +677,9 @@ class SessionCoordinator {
}
_seekGate.reset();
_clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1;
await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId));
}
_jellyfinTransport(r);
_relayStatus(r);
@@ -1060,8 +1105,11 @@ class SessionCoordinator {
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) {
// A web link: its name is a URL tail ("1"), not a show; never guess.
bytes = null;
// A web link is not a show: never guess. A page's thumbnail is
// its banner.
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1089,33 +1137,58 @@ class SessionCoordinator {
_swap(media.copyWith(relay: const MediaRelay()));
return null;
}
LinkProbe? probe;
String? probeError;
try {
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) {
_swap(media);
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
return null;
}
// Relayed: it must be a single file the relay can range-read (not a
// page like YouTube, which only the player can resolve).
final probe = HttpRelayBytes(Uri.parse(url));
final int size;
try {
size = await probe.size().timeout(const Duration(seconds: 15));
} catch (e) {
return relay == LinkRelay.me
? 'cannot pass this link on: $e'
: 'cannot check this link: $e';
} finally {
await probe.close();
}
if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url);
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
} else {
_swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
// This computer reads it: a file, or a page's single-file format.
if (probe == null) return 'cannot pass this link on: $probeError';
final problem = probe.relayProblem;
if (problem != null) return problem;
final y = await ytDlp.ytDlp;
final check = LinkRelayBytes(uri, ytDlp: y, probe: probe);
final int size;
try {
size = await check.size().timeout(const Duration(seconds: 15));
} catch (e) {
return 'cannot pass this link on: $e';
} finally {
await check.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(name: name, size: size, duration: 0, relay: const MediaRelay()));
return null;
}
// The server reads it with its own yt-dlp; only a page this app knows
// has no single file is refused here (the server may reach what this
// computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) {
return noSingleFileText;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
return null;
}
Future<LinkProbe?> _probeFor(String url) async {
try {
return await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (_) {
return null;
}
}
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) {