Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,36 @@ private static BasisMediaSource SelectSource(VideoInfo info)
return new BasisMediaSource { Uri = video.Url, AudioUri = audio.Url, Delivery = BasisMediaDelivery.OnDemand };

Format muxed = BestMuxed(info.Formats);
if (muxed != null) return new BasisMediaSource { Uri = muxed.Url };
if (muxed != null) return new BasisMediaSource { Uri = muxed.Url, Delivery = DeliveryFor(info) };

// Last resort: yt-dlp's top-level URL — but only if the player can open it
// directly. An unvalidated DirectUrl can be an unsupported manifest/codec that
// would bypass the avc1/mp4a filtering above, so reject it and let
// ResolveSourceAsync fail loudly ("no player-ingestible format") instead.
if (!string.IsNullOrEmpty(info.DirectUrl) && BasisMediaUrlRouter.IsDirectlyPlayable(info.DirectUrl))
return new BasisMediaSource { Uri = info.DirectUrl };
return new BasisMediaSource { Uri = info.DirectUrl, Delivery = DeliveryFor(info) };

return null;
}

// Maps yt-dlp's live-status metadata onto the engine's delivery hint, so the
// live-vs-VOD clock is chosen at open instead of sniffed from the byte stream.
// Absent/unknown status falls back to Auto (the engine detects a seekable,
// finite body as VOD and an open-ended stream as live). The split avc1+mp4a
// path doesn't consult this — that pairing is only ever adaptive VOD.
private static BasisMediaDelivery DeliveryFor(VideoInfo info)
{
if (info.IsLive == true) return BasisMediaDelivery.Live;
switch (info.LiveStatus)
{
case "is_live":
case "is_upcoming": return BasisMediaDelivery.Live;
case "was_live":
case "not_live": return BasisMediaDelivery.OnDemand;
default: return BasisMediaDelivery.Auto;
}
}

// H.264 video-only, no higher than 1080p (avc1 is the player's ceiling — no
// VP9/AV1 decode), best height then bitrate, direct byte URL (not a manifest).
private static Format BestVideoOnly(List<Format> formats)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,39 @@
namespace Basis.Integration.YtDlp
{
/// <summary>
/// Installs the yt-dlp resolver into <see cref="BasisMediaUrlRouter"/> at startup,
/// so any player URL field (e.g. <c>BasisMediaPlayerStreaming</c>) steers page URLs
/// through yt-dlp while directly-playable streams load unchanged. The player core
/// holds no reference to this package; removing the package removes the
/// registration (the router falls back to direct loads), with nothing dangling.
/// Registers the yt-dlp resolver with <see cref="BasisMediaUrlRouter"/> at startup, so any
/// player URL field (e.g. <c>BasisMediaPlayerStreaming</c>) steers page URLs through yt-dlp
/// while directly-playable streams load unchanged. The player core holds no reference to this
/// package; removing the package removes the registration (the router falls back to direct
/// loads, or to another registered resolver), with nothing dangling.
/// </summary>
internal static class BasisYtDlpRouterInstaller
{
private static BasisYtDlpVideoResolver installed;

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Install()
{
// The router holds a single resolver slot. Don't clobber another package's resolver
// if one is already installed — first-come wins, and the collision is surfaced rather
// than silently overwritten.
if (BasisMediaUrlRouter.Resolver != null)
{
BasisDebug.LogWarning(
"A BasisMediaUrlRouter resolver is already installed; the yt-dlp resolver will not replace it.",
BasisDebug.LogTag.Video);
return;
}
if (installed != null) return; // idempotent across domain reloads
installed = new BasisYtDlpVideoResolver();
BasisMediaUrlRouter.Register(installed);
}
}

BasisMediaUrlRouter.Resolver = (player, url) =>
{
// Already directly playable (transport scheme or media-extension URL):
// decline so the caller loads it directly. Otherwise it's a page URL —
// resolve it to its stream(s) and load (async).
if (!BasisYtDlpResolver.NeedsResolution(url)) return false;
BasisYtDlpResolver.ResolveAndPlay(player, url);
return true;
};
/// <summary>
/// Routes page URLs (YouTube, Twitch, …) through the in-process yt-dlp resolver. Directly
/// playable URLs are declined via <see cref="CanResolve"/> so the player opens them itself.
/// </summary>
internal sealed class BasisYtDlpVideoResolver : IBasisVideoResolver
{
public int Priority => 0;

public bool CanResolve(string url) => BasisYtDlpResolver.NeedsResolution(url);

public bool TryResolve(BasisMediaPlayer player, string url)
{
BasisYtDlpResolver.ResolveAndPlay(player, url);
return true;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,14 @@ private void Awake()
// already-resolved or direct sources, e.g. the resolver's own output).
public void LoadUrl(string url)
{
if (string.IsNullOrEmpty(url))
if (string.IsNullOrWhiteSpace(url))
{
BasisDebug.LogWarning("BasisMediaPlayer.LoadUrl called with empty URL.", BasisDebug.LogTag.Video);
return;
}
// Default a missing scheme to https so a bare "www.example.com/…" routes and loads
// as an absolute URL instead of being mis-read as a direct/transport source.
url = BasisMediaUrlRouter.NormalizeUrl(url);
LastErrorMessage = null;
LoadGeneration++;
if (BasisMediaUrlRouter.TryResolveAndLoad(this, url)) return;
Expand All @@ -445,7 +448,7 @@ public void LoadUrl(string url)
// streaming-assets-relative path and calls LoadSource.
public void LoadLocalPath(string path)
{
if (string.IsNullOrEmpty(path))
if (string.IsNullOrWhiteSpace(path))
{
BasisDebug.LogWarning("BasisMediaPlayer.LoadLocalPath called with empty path.", BasisDebug.LogTag.Video);
return;
Expand Down
138 changes: 118 additions & 20 deletions Basis/Packages/com.basis.mediaplayer/Runtime/BasisMediaUrlRouter.cs
Original file line number Diff line number Diff line change
@@ -1,40 +1,109 @@
using System;
using System.Collections.Generic;

/// <summary>
/// Optional URL-resolution seam between the player and a higher-level resolver
/// (e.g. the yt-dlp integration package). The player core has no reference to any
/// integration, so this is how a URL field can steer page URLs through a resolver
/// without the player depending on it.
/// A URL resolver registered with <see cref="BasisMediaUrlRouter"/>. Several resolvers
/// can be registered at once; the router tries them in descending <see cref="Priority"/>
/// order (registration order breaks ties) until one takes ownership of the load. A
/// resolver that declines lets the router fall through to the next one, and finally to a
/// direct load. Resolvers route only — they never gate host trust (that's
/// BasisMediaPlayerSecurity).
/// </summary>
public interface IBasisVideoResolver
{
/// <summary>Higher runs first; equal priorities run in the order they registered.</summary>
int Priority { get; }

/// <summary>
/// Cheap, side-effect-free test of whether this resolver handles <paramref name="url"/>.
/// Returning false skips straight to the next resolver. Must not block or load.
/// </summary>
bool CanResolve(string url);

/// <summary>
/// Takes ownership of loading <paramref name="url"/> into <paramref name="player"/>
/// — resolving a page URL to its stream(s) and loading them, possibly async — and
/// returns true; or returns false to let the router try the next resolver, then a
/// direct load.
/// </summary>
bool TryResolve(BasisMediaPlayer player, string url);
}

/// <summary>
/// Optional URL-resolution seam between the player and higher-level resolvers (e.g. the
/// yt-dlp integration package). The player core has no reference to any integration, so
/// this is how a URL field can steer page URLs through a resolver without the player
/// depending on it.
///
/// Integrations <see cref="Register"/> an <see cref="IBasisVideoResolver"/> at load (e.g.
/// via <c>RuntimeInitializeOnLoadMethod</c>). Callers with a raw URL hand it to
/// <see cref="TryResolveAndLoad"/>, which walks the registered resolvers in priority order
/// until one takes ownership (returns true); if none do, the caller loads the URL directly.
/// With nothing registered every URL loads directly — identical to having no integration at
/// all. This routes only; it never blocks a URL (host trust is enforced separately by
/// BasisMediaPlayerSecurity).
///
/// An integration installs <see cref="Resolver"/> at load (e.g. via
/// <c>RuntimeInitializeOnLoadMethod</c>). Callers with a raw URL hand it to
/// <see cref="TryResolveAndLoad"/>: the resolver either takes ownership of the load
/// (resolving a page URL to its stream(s) and loading them, possibly async) and
/// returns true, or returns false to let the caller load the URL directly. When no
/// integration is installed <see cref="Resolver"/> is null and every URL loads
/// directly — identical to having no integration at all. This routes only; it never
/// blocks a URL (host trust is enforced separately by BasisMediaPlayerSecurity).
/// Not thread-safe. Register, Unregister and TryResolveAndLoad must all run on Unity's main
/// thread — the resolver list is unsynchronised, so registering while a resolve is iterating
/// would corrupt it. Integrations register from <c>RuntimeInitializeOnLoadMethod</c> and the
/// player resolves from its load path, both main-thread, so this holds in practice.
/// </summary>
public static class BasisMediaUrlRouter
{
private static readonly List<IBasisVideoResolver> Resolvers = new List<IBasisVideoResolver>();
private static LegacyResolverAdapter legacy;

/// <summary>
/// Installed by an optional integration. Returns true if it took ownership of the
/// load for the given URL, false to let the caller load it directly. Null when no
/// integration is present.
/// Back-compatible single-resolver slot. Assigning a non-null delegate registers it as a
/// priority-0 resolver; assigning null removes it. Prefer <see cref="Register"/> with an
/// <see cref="IBasisVideoResolver"/> for new integrations — this is kept so existing
/// callers that set a delegate keep working.
/// </summary>
public static Func<BasisMediaPlayer, string, bool> Resolver;
public static Func<BasisMediaPlayer, string, bool> Resolver
{
get => legacy?.Func;
set
{
if (legacy != null) { Unregister(legacy); legacy = null; }
if (value != null) { legacy = new LegacyResolverAdapter(value); Register(legacy); }
}
}

/// <summary>
/// Registers <paramref name="resolver"/> so <see cref="TryResolveAndLoad"/> consults it,
/// keeping the list ordered by descending <see cref="IBasisVideoResolver.Priority"/>
/// (registration order breaks ties). Registering the same instance twice is a no-op.
/// </summary>
public static void Register(IBasisVideoResolver resolver)
{
if (resolver == null) throw new ArgumentNullException(nameof(resolver));
if (Resolvers.Contains(resolver)) return;
int i = 0;
while (i < Resolvers.Count && Resolvers[i].Priority >= resolver.Priority) i++;
Resolvers.Insert(i, resolver);
}

/// <summary>Removes a resolver registered via <see cref="Register"/>. Returns false if it wasn't registered.</summary>
public static bool Unregister(IBasisVideoResolver resolver) => Resolvers.Remove(resolver);

// Delimiters that end the path portion of a URL (query / fragment), hoisted so
// IsDirectlyPlayable doesn't allocate a char[] per call.
private static readonly char[] PathEnd = { '?', '#' };

/// <summary>
/// Routes <paramref name="url"/> through the installed resolver, if any. Returns
/// true when the resolver took ownership (the caller should not also load); false
/// when there is no resolver or it declined (the caller loads directly).
/// Routes <paramref name="url"/> through the registered resolvers in priority order.
/// Returns true as soon as one takes ownership (the caller should not also load); false
/// when none handle it (the caller loads directly).
/// </summary>
public static bool TryResolveAndLoad(BasisMediaPlayer player, string url)
=> Resolver != null && Resolver(player, url);
{
for (int i = 0; i < Resolvers.Count; i++)
{
IBasisVideoResolver resolver = Resolvers[i];
if (resolver.CanResolve(url) && resolver.TryResolve(player, url)) return true;
}
return false;
}

/// <summary>
/// True if the player can open <paramref name="url"/> directly, without a resolver:
Expand Down Expand Up @@ -67,4 +136,33 @@ public static bool IsDirectlyPlayable(string url)
|| path.EndsWith(".mts", StringComparison.OrdinalIgnoreCase) // AVCHD-flavour MPEG-TS
|| path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Guarantees <paramref name="url"/> carries a scheme so it can route and load as an
/// absolute URL. A bare web URL with no scheme (e.g. "www.youtube.com/watch?v=…") gets
/// "https://" prepended; anything that already has a scheme (http(s)/rtsp/rtmp/rist/file/…)
/// or is a local path (leading slash, UNC, or a drive letter like C:\) is returned trimmed
/// but otherwise unchanged. Null/whitespace passes through untouched.
/// </summary>
public static string NormalizeUrl(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
string trimmed = url.Trim();
if (trimmed.Contains("://")) return trimmed; // already has a scheme
if (trimmed[0] == '/' || trimmed[0] == '\\') return trimmed; // unix / UNC / rooted path
if (trimmed.Length >= 2 && trimmed[1] == ':') return trimmed; // windows drive path (C:\, C:/)
return "https://" + trimmed;
}

// Adapts a legacy Resolver delegate to IBasisVideoResolver. The delegate self-selects
// (returns false to decline), so CanResolve is always true and the decision happens in
// TryResolve — preserving the original single-slot semantics.
private sealed class LegacyResolverAdapter : IBasisVideoResolver
{
internal readonly Func<BasisMediaPlayer, string, bool> Func;
public LegacyResolverAdapter(Func<BasisMediaPlayer, string, bool> func) => Func = func;
public int Priority => 0;
public bool CanResolve(string url) => true;
public bool TryResolve(BasisMediaPlayer player, string url) => Func(player, url);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,13 @@ private void BuildControlGroup(RectTransform parent)
{
if (_activePlayer == null || _urlField == null) return;
string u = _urlField.Value;
if (string.IsNullOrEmpty(u)) return;
if (_activeNetworking != null) _ = _activeNetworking.SetUrl(u);
else _activePlayer.LoadUrl(u);
if (string.IsNullOrWhiteSpace(u)) return;
// Reflect the scheme we add back into the field so a bare "youtube.com/…"
// visibly becomes "https://youtube.com/…" rather than being silently rewritten.
string normalized = BasisMediaUrlRouter.NormalizeUrl(u);
if (normalized != u) _urlField.SetValueWithoutNotify(normalized);
if (_activeNetworking != null) _ = _activeNetworking.SetUrl(normalized);
else _activePlayer.LoadUrl(normalized);
};

PanelButton playBtn = PanelButton.CreateNew(actions);
Expand Down
Loading