mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
When the same video was already loaded (typically paused), opening it again via the URL scheme, a deep link, or a remote-control loadVideo command did nothing — the player just stayed paused. Now the same-video early-return path resumes playback if paused and seeks to the supplied startTime, so timestamps from URLs and remotes are honoured even when the video is already loaded. URLRouter gains a parseTimestamp helper that reads t/time/start query params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep link handler now forwards that timestamp through to openVideo.
418 lines
14 KiB
Swift
418 lines
14 KiB
Swift
//
|
|
// URLRouter.swift
|
|
// Yattee
|
|
//
|
|
// URL parsing and routing for deep links and shared URLs.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Routes URLs to navigation destinations.
|
|
struct URLRouter: Sendable {
|
|
|
|
// MARK: - Main Routing
|
|
|
|
/// Route a URL only if we are *confident* the app can handle it natively —
|
|
/// YouTube/PeerTube video/channel/playlist, direct media (mp4/m3u8/etc),
|
|
/// or the custom `yattee://` scheme. Unlike `route(_:)` this deliberately
|
|
/// skips the `.externalVideo` yt-dlp fallback, which matches almost any
|
|
/// http/https URL and is therefore unsafe to trigger blindly after
|
|
/// resolving a URL shortener.
|
|
func routeConfidently(_ url: URL) -> NavigationDestination? {
|
|
guard let destination = route(url) else { return nil }
|
|
if case .externalVideo = destination { return nil }
|
|
return destination
|
|
}
|
|
|
|
/// Route a URL to a navigation destination.
|
|
func route(_ url: URL) -> NavigationDestination? {
|
|
// Try custom scheme first
|
|
if url.scheme == "yattee" {
|
|
return parseCustomScheme(url)
|
|
}
|
|
|
|
// Try YouTube playlist URLs first (before video URLs since playlist pages can have v= param)
|
|
if let playlistID = parseYouTubePlaylistURL(url) {
|
|
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
|
|
}
|
|
|
|
// Try YouTube channel URLs
|
|
if let channelID = parseYouTubeChannelURL(url) {
|
|
return .channel(channelID, .global(provider: ContentSource.youtubeProvider))
|
|
}
|
|
|
|
// Try YouTube video URLs
|
|
if let videoID = parseYouTubeURL(url) {
|
|
return .video(.id(.global(videoID)))
|
|
}
|
|
|
|
// Try PeerTube URLs
|
|
if let (instance, videoID) = parsePeerTubeURL(url) {
|
|
return .video(.id(.federated(videoID, instance: instance, uuid: nil)))
|
|
}
|
|
|
|
// Try direct media URLs (mp4, m3u8, etc.) - no extraction needed
|
|
if DirectMediaHelper.isDirectMediaURL(url) {
|
|
return .directMedia(url)
|
|
}
|
|
|
|
// Fallback: Try external URL extraction for any http/https URL
|
|
// This will be handled by Yattee Server using yt-dlp
|
|
if isExternalVideoURL(url) {
|
|
return .externalVideo(url)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - External URL Detection
|
|
|
|
/// Check if URL might be an external video that yt-dlp can handle.
|
|
private func isExternalVideoURL(_ url: URL) -> Bool {
|
|
// Must be http or https
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" else {
|
|
return false
|
|
}
|
|
|
|
// Must have a host
|
|
guard let host = url.host?.lowercased(), !host.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
// Skip known non-video sites
|
|
let excludedHosts = [
|
|
"google.com", "www.google.com",
|
|
"bing.com", "www.bing.com",
|
|
"duckduckgo.com",
|
|
"apple.com", "www.apple.com",
|
|
"github.com", "www.github.com"
|
|
]
|
|
|
|
if excludedHosts.contains(host) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Timestamp Parsing
|
|
|
|
/// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`.
|
|
/// Accepts plain seconds (`90`, `90.5`), YouTube-style `90s`, and `1h2m3s` / `2m30s` forms.
|
|
func parseTimestamp(_ url: URL) -> TimeInterval? {
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let items = components.queryItems else {
|
|
return nil
|
|
}
|
|
for name in ["t", "time", "start"] {
|
|
if let raw = items.first(where: { $0.name == name })?.value,
|
|
let parsed = Self.parseTimestampValue(raw) {
|
|
return parsed
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Parse a single timestamp string. Returns nil if unparseable or zero-length.
|
|
static func parseTimestampValue(_ raw: String) -> TimeInterval? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
// Plain numeric value (e.g. "90", "90.5")
|
|
if let value = TimeInterval(trimmed) {
|
|
return value >= 0 ? value : nil
|
|
}
|
|
|
|
// Compound form like "1h2m3s", "2m30s", "90s"
|
|
var total: TimeInterval = 0
|
|
var current = ""
|
|
var matched = false
|
|
for ch in trimmed {
|
|
if ch.isNumber || ch == "." {
|
|
current.append(ch)
|
|
} else {
|
|
guard let value = TimeInterval(current) else { return nil }
|
|
let unit: TimeInterval
|
|
switch ch {
|
|
case "h", "H": unit = 3600
|
|
case "m", "M": unit = 60
|
|
case "s", "S": unit = 1
|
|
default: return nil
|
|
}
|
|
total += value * unit
|
|
current = ""
|
|
matched = true
|
|
}
|
|
}
|
|
// Trailing digits without a unit suffix (e.g. "1m30") — treat as seconds.
|
|
if !current.isEmpty, let value = TimeInterval(current) {
|
|
total += value
|
|
matched = true
|
|
}
|
|
return matched ? total : nil
|
|
}
|
|
|
|
// MARK: - Custom Scheme
|
|
|
|
/// Parse yattee:// scheme URLs.
|
|
private func parseCustomScheme(_ url: URL) -> NavigationDestination? {
|
|
guard let host = url.host else { return nil }
|
|
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
|
|
switch host {
|
|
case "video":
|
|
// yattee://video/{videoId}?source={source}&instance={url}
|
|
let videoID = url.lastPathComponent
|
|
guard !videoID.isEmpty else { return nil }
|
|
|
|
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
|
|
|
if sourceParam == "peertube",
|
|
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
|
let instanceURL = URL(string: instanceStr) {
|
|
return .video(.id(.federated(videoID, instance: instanceURL, uuid: nil)))
|
|
}
|
|
|
|
return .video(.id(.global(videoID)))
|
|
|
|
case "channel":
|
|
// yattee://channel/{channelId}?source={source}&instance={url}
|
|
let channelID = url.lastPathComponent
|
|
guard !channelID.isEmpty else { return nil }
|
|
|
|
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
|
let source: ContentSource
|
|
if sourceParam == "peertube",
|
|
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
|
let instanceURL = URL(string: instanceStr) {
|
|
source = .federated(provider: ContentSource.peertubeProvider, instance: instanceURL)
|
|
} else {
|
|
source = .global(provider: ContentSource.youtubeProvider)
|
|
}
|
|
|
|
return .channel(channelID, source)
|
|
|
|
case "playlist":
|
|
// yattee://playlist/{playlistId}
|
|
let playlistID = url.lastPathComponent
|
|
guard !playlistID.isEmpty else { return nil }
|
|
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
|
|
|
|
case "search":
|
|
// yattee://search?q={query}
|
|
guard let query = components?.queryItems?.first(where: { $0.name == "q" })?.value,
|
|
!query.isEmpty else {
|
|
return nil
|
|
}
|
|
return .search(query)
|
|
|
|
case "playlists":
|
|
// yattee://playlists
|
|
return .playlists
|
|
|
|
case "bookmarks":
|
|
// yattee://bookmarks
|
|
return .bookmarks
|
|
|
|
case "history":
|
|
// yattee://history
|
|
return .history
|
|
|
|
case "downloads":
|
|
// yattee://downloads
|
|
return .downloads
|
|
|
|
case "channels":
|
|
// yattee://channels (manage subscribed channels)
|
|
return .manageChannels
|
|
|
|
case "subscriptions":
|
|
// yattee://subscriptions
|
|
return .subscriptionsFeed
|
|
|
|
case "continue-watching":
|
|
// yattee://continue-watching
|
|
return .continueWatching
|
|
|
|
case "settings":
|
|
// yattee://settings
|
|
return .settings
|
|
|
|
case "open":
|
|
// yattee://open?url={encoded_url} - from share extension
|
|
if let urlParam = components?.queryItems?.first(where: { $0.name == "url" })?.value,
|
|
let decodedURL = URL(string: urlParam) {
|
|
// Route the decoded URL through normal routing
|
|
return route(decodedURL)
|
|
}
|
|
return nil
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - YouTube URL Parsing
|
|
|
|
/// Parse YouTube URLs and extract video ID.
|
|
private func parseYouTubeURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
// youtube.com/watch?v=VIDEO_ID
|
|
if host.contains("youtube.com") {
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
if let videoID = components?.queryItems?.first(where: { $0.name == "v" })?.value {
|
|
return videoID
|
|
}
|
|
|
|
// youtube.com/shorts/VIDEO_ID
|
|
if url.pathComponents.contains("shorts"),
|
|
let index = url.pathComponents.firstIndex(of: "shorts"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
|
|
// youtube.com/embed/VIDEO_ID
|
|
if url.pathComponents.contains("embed"),
|
|
let index = url.pathComponents.firstIndex(of: "embed"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
|
|
// youtube.com/live/VIDEO_ID
|
|
if url.pathComponents.contains("live"),
|
|
let index = url.pathComponents.firstIndex(of: "live"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
}
|
|
|
|
// youtu.be/VIDEO_ID
|
|
if host == "youtu.be" {
|
|
let videoID = url.lastPathComponent
|
|
if !videoID.isEmpty && videoID != "/" {
|
|
return videoID
|
|
}
|
|
}
|
|
|
|
// m.youtube.com
|
|
if host == "m.youtube.com" {
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
return components?.queryItems?.first(where: { $0.name == "v" })?.value
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - PeerTube URL Parsing
|
|
|
|
/// Parse PeerTube URLs and extract instance URL and video ID.
|
|
private func parsePeerTubeURL(_ url: URL) -> (URL, String)? {
|
|
// PeerTube URLs are typically:
|
|
// https://instance.tld/w/VIDEO_ID
|
|
// https://instance.tld/videos/watch/VIDEO_ID
|
|
|
|
guard let host = url.host,
|
|
let scheme = url.scheme else {
|
|
return nil
|
|
}
|
|
|
|
// Skip known non-PeerTube hosts
|
|
let nonPeerTubeHosts = [
|
|
"youtube.com", "www.youtube.com", "m.youtube.com",
|
|
"youtu.be", "music.youtube.com",
|
|
"vimeo.com", "www.vimeo.com",
|
|
"dailymotion.com", "www.dailymotion.com"
|
|
]
|
|
|
|
if nonPeerTubeHosts.contains(host) {
|
|
return nil
|
|
}
|
|
|
|
let pathComponents = url.pathComponents
|
|
|
|
// /w/VIDEO_ID or /videos/watch/VIDEO_ID
|
|
if pathComponents.contains("w") || pathComponents.contains("videos") {
|
|
var videoID: String?
|
|
|
|
if let wIndex = pathComponents.firstIndex(of: "w"),
|
|
pathComponents.count > wIndex + 1 {
|
|
videoID = pathComponents[wIndex + 1]
|
|
} else if let watchIndex = pathComponents.firstIndex(of: "watch"),
|
|
pathComponents.count > watchIndex + 1 {
|
|
videoID = pathComponents[watchIndex + 1]
|
|
}
|
|
|
|
if let videoID, !videoID.isEmpty {
|
|
let instanceURL = URL(string: "\(scheme)://\(host)")!
|
|
return (instanceURL, videoID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Channel URL Parsing
|
|
|
|
/// Parse YouTube channel URLs.
|
|
func parseYouTubeChannelURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
guard host.contains("youtube.com") else { return nil }
|
|
|
|
let pathComponents = url.pathComponents
|
|
|
|
// youtube.com/channel/CHANNEL_ID
|
|
if let channelIndex = pathComponents.firstIndex(of: "channel"),
|
|
pathComponents.count > channelIndex + 1 {
|
|
return pathComponents[channelIndex + 1]
|
|
}
|
|
|
|
// youtube.com/@HANDLE
|
|
if let component = pathComponents.first(where: { $0.hasPrefix("@") }) {
|
|
return component
|
|
}
|
|
|
|
// youtube.com/c/CUSTOM_NAME
|
|
if let cIndex = pathComponents.firstIndex(of: "c"),
|
|
pathComponents.count > cIndex + 1 {
|
|
return pathComponents[cIndex + 1]
|
|
}
|
|
|
|
// youtube.com/user/USERNAME
|
|
if let userIndex = pathComponents.firstIndex(of: "user"),
|
|
pathComponents.count > userIndex + 1 {
|
|
return pathComponents[userIndex + 1]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Playlist URL Parsing
|
|
|
|
/// Parse YouTube playlist URLs and extract playlist ID.
|
|
private func parseYouTubePlaylistURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
guard host.contains("youtube.com") else { return nil }
|
|
|
|
// youtube.com/playlist?list=PLAYLIST_ID
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
if let listParam = components?.queryItems?.first(where: { $0.name == "list" })?.value {
|
|
// Only return if this is primarily a playlist URL (path is /playlist)
|
|
// or if there's no video ID (pure playlist link)
|
|
let isPlaylistPath = url.pathComponents.contains("playlist")
|
|
let hasVideoID = components?.queryItems?.first(where: { $0.name == "v" })?.value != nil
|
|
|
|
// Return playlist ID only if it's a playlist page or watch page without video ID
|
|
if isPlaylistPath || !hasVideoID {
|
|
return listParam
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|