forked from Foxyz/sync_replay_project
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:
1 parent
9ddd85de90
commit
745717a4a0
40 files changed
+1444
-123
No files matched your search
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
|
||||
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../state/connect_prefs.dart';
|
||||
import 'motion.dart';
|
||||
@@ -109,49 +110,49 @@ class LinkPreview {
|
||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||
}
|
||||
|
||||
/// GET [uri] and return the body (oEmbed).
|
||||
typedef OEmbedGetter = Future<String> Function(Uri uri);
|
||||
/// What a link is (see [AppYtDlp.probe]).
|
||||
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||
|
||||
Future<String> defaultOEmbedGet(Uri uri) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final res = await req.close().timeout(const Duration(seconds: 8));
|
||||
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
|
||||
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
bool isYouTubeLink(Uri uri) {
|
||||
final h = uri.host.toLowerCase();
|
||||
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
|
||||
}
|
||||
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||
|
||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||
|
||||
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
|
||||
/// channel and thumbnail; anything else (or a failed lookup) shows the
|
||||
/// file name and host. Null for text that is not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
|
||||
/// "3:07", "1:02:03".
|
||||
String clockText(double seconds) {
|
||||
final s = seconds.round();
|
||||
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
|
||||
}
|
||||
|
||||
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||
/// duration and thumbnail (or why it is not a video); a media file (by
|
||||
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
|
||||
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
|
||||
if (!isYouTubeLink(uri)) return plain;
|
||||
final host = _host(uri);
|
||||
// A media file name needs no lookup.
|
||||
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
final LinkProbe p;
|
||||
try {
|
||||
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
|
||||
final j = jsonDecode(body);
|
||||
if (j is! Map || j['title'] is! String) return plain;
|
||||
final author = j['author_name'];
|
||||
return LinkPreview(
|
||||
title: j['title'] as String,
|
||||
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
|
||||
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
|
||||
);
|
||||
} catch (_) {
|
||||
return plain;
|
||||
p = await probe(url.trim());
|
||||
} catch (e) {
|
||||
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
|
||||
}
|
||||
final info = p.info;
|
||||
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||
if (info == null) {
|
||||
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
|
||||
}
|
||||
return LinkPreview(
|
||||
title: p.title ?? host,
|
||||
subtitle: [
|
||||
host,
|
||||
if (info.uploader.isNotEmpty) info.uploader,
|
||||
if (info.duration case final d? when d > 0) clockText(d),
|
||||
].join(' · '),
|
||||
thumbnail: info.thumbnail,
|
||||
);
|
||||
}
|
||||
|
||||
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||
@@ -195,14 +196,14 @@ class LibraryLinkSource extends StatefulWidget {
|
||||
/// Puts the link up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||
final RecentLinks recents;
|
||||
final OEmbedGetter oembed;
|
||||
final LinkProber probe;
|
||||
const LibraryLinkSource({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onPutUp,
|
||||
required this.recents,
|
||||
this.offlineHint,
|
||||
this.oembed = defaultOEmbedGet,
|
||||
this.probe = defaultLinkProber,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -246,7 +247,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
final p = await previewLink(url, get: widget.oembed);
|
||||
final p = await previewLink(url, probe: widget.probe);
|
||||
_previews[url] = p;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@@ -272,7 +273,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
error = '$e';
|
||||
}
|
||||
if (error == null) {
|
||||
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
|
||||
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
|
||||
unawaited(widget.recents.add(RecentLink(
|
||||
url: url,
|
||||
title: p?.title ?? LinkMedia(url).label,
|
||||
@@ -346,7 +347,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
|
||||
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
|
||||
@@ -83,7 +83,7 @@ class LibraryTab extends StatefulWidget {
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
final RecentLinks? recentLinks;
|
||||
final OEmbedGetter oembed;
|
||||
final LinkProber probe;
|
||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||
final Widget Function(BuildContext context, {required bool online, required bool relayed,
|
||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||
@@ -100,7 +100,7 @@ class LibraryTab extends StatefulWidget {
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
this.recentLinks,
|
||||
this.oembed = defaultOEmbedGet,
|
||||
this.probe = defaultLinkProber,
|
||||
this.filesBuilder,
|
||||
this.now = DateTime.now,
|
||||
});
|
||||
@@ -245,7 +245,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
controller: _c,
|
||||
offlineHint: _hint,
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
oembed: widget.oembed,
|
||||
probe: widget.probe,
|
||||
onPutUp: _put,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show YtDlp;
|
||||
|
||||
import '../controls/player_settings.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../playback/yt_dlp_app.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
@@ -107,6 +109,7 @@ class SettingsTab extends StatelessWidget {
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||
),
|
||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||
];
|
||||
case SettingsSection.integrations:
|
||||
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
||||
@@ -352,6 +355,67 @@ class RecentServersPanel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||
/// last error.
|
||||
class YtDlpSettings extends StatefulWidget {
|
||||
/// Defaults to [AppYtDlp.instance]'s.
|
||||
final Future<YtDlp>? ytDlp;
|
||||
const YtDlpSettings({super.key, this.ytDlp});
|
||||
|
||||
@override
|
||||
State<YtDlpSettings> createState() => _YtDlpSettingsState();
|
||||
}
|
||||
|
||||
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return FutureBuilder<YtDlp>(
|
||||
future: _y,
|
||||
builder: (context, snap) {
|
||||
final y = snap.data;
|
||||
if (y == null) return const SizedBox.shrink();
|
||||
return StreamBuilder<void>(
|
||||
stream: y.changes,
|
||||
builder: (context, _) {
|
||||
final err = y.lastError;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.travel_explore),
|
||||
title: Text(y.busy
|
||||
? 'Downloading…'
|
||||
: y.version == null
|
||||
? 'Not downloaded yet'
|
||||
: 'Version ${y.version}'),
|
||||
subtitle: err == null
|
||||
? null
|
||||
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
|
||||
trailing: TextButton(
|
||||
onPressed: y.busy
|
||||
? null
|
||||
: () => y.path == null ? y.ensure() : y.update(force: true),
|
||||
child: const Text('Update now'),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Plays links to web pages (YouTube, X, …) in all three link modes. '
|
||||
'Downloaded and kept up to date by the app; checked once a day.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
||||
/// rights, or why not.
|
||||
class AnimeFlowCard extends StatefulWidget {
|
||||
|
||||
Reference in new issue
Block a user