mirror of
https://github.com/yattee/yattee.git
synced 2026-08-26 00:42:33 +00:00
DescriptionText.parseTimestamp turned a matched timestamp string into a
clickable seek link by computing seconds from its colon-separated parts
with no range validation. The link regex (\d{1,2}:\d{2}(?::\d{2})?)
matches strings that are not valid clock positions, such as 1:99 or
0:60, and parseTimestamp happily computed 1*60+99 = 159 for 1:99 — so
tapping such a link seeked the player to 2:39 instead of being ignored.
This also diverged from ChapterParser, which rejects seconds/minutes
>= 60; the same timestamp string could be dropped as a chapter but
still seek as a description link.
- Validate seconds < 60 in the MM:SS branch and minutes < 60, seconds
< 60 in the H:MM:SS branch; return nil otherwise
- Change parseTimestamp to return Int? and skip building the seek link
for nil, so out-of-range matches are left as plain text
- Add DescriptionTextTests with valid, boundary, and out-of-range cases
194 lines
7.9 KiB
Swift
194 lines
7.9 KiB
Swift
//
|
|
// DescriptionText.swift
|
|
// Yattee
|
|
//
|
|
// Utilities for parsing and formatting video description text with clickable links and timestamps.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
// MARK: - Description Text Utilities
|
|
|
|
enum DescriptionText {
|
|
/// Creates an attributed string with clickable URLs and timestamps.
|
|
/// Timestamps are converted to `yattee-seek://SECONDS` URLs.
|
|
static func attributed(_ text: String, linkColor: Color = .accentColor) -> AttributedString {
|
|
var attributedString = AttributedString(text)
|
|
|
|
// URL regex pattern
|
|
let urlPattern = #"https?://[^\s<>\"\']+"#
|
|
|
|
if let regex = try? NSRegularExpression(pattern: urlPattern, options: []) {
|
|
let nsRange = NSRange(text.startIndex..., in: text)
|
|
let matches = regex.matches(in: text, options: [], range: nsRange)
|
|
|
|
for match in matches {
|
|
guard let range = Range(match.range, in: text),
|
|
let attributedRange = Range(range, in: attributedString),
|
|
let url = URL(string: String(text[range])) else {
|
|
continue
|
|
}
|
|
|
|
attributedString[attributedRange].link = url
|
|
attributedString[attributedRange].foregroundColor = linkColor
|
|
}
|
|
}
|
|
|
|
// Timestamp pattern: matches formats like 0:00, 00:00, 0:00:00, 00:00:00
|
|
// Must be at word boundary (not part of a larger number sequence)
|
|
let timestampPattern = #"(?<![:\d])(\d{1,2}:\d{2}(?::\d{2})?)(?![:\d])"#
|
|
|
|
if let timestampRegex = try? NSRegularExpression(pattern: timestampPattern, options: []) {
|
|
let nsRange = NSRange(text.startIndex..., in: text)
|
|
let matches = timestampRegex.matches(in: text, options: [], range: nsRange)
|
|
|
|
for match in matches {
|
|
guard let range = Range(match.range, in: text),
|
|
let attributedRange = Range(range, in: attributedString) else {
|
|
continue
|
|
}
|
|
|
|
let timestampString = String(text[range])
|
|
guard let seconds = parseTimestamp(timestampString),
|
|
let url = URL(string: "yattee-seek://\(seconds)") else {
|
|
continue
|
|
}
|
|
|
|
attributedString[attributedRange].link = url
|
|
attributedString[attributedRange].foregroundColor = linkColor
|
|
}
|
|
}
|
|
|
|
return attributedString
|
|
}
|
|
|
|
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
|
|
/// Returns nil when a component is out of its valid range (e.g. `1:99`,
|
|
/// `0:60`, `1:99:30`); those strings are matched by the link regex above
|
|
/// but are not valid clock positions, so they must not become seek links.
|
|
/// This mirrors the range validation in `ChapterParser`.
|
|
static func parseTimestamp(_ timestamp: String) -> Int? {
|
|
let components = timestamp.split(separator: ":").compactMap { Int($0) }
|
|
switch components.count {
|
|
case 2: // MM:SS
|
|
let seconds = components[1]
|
|
guard seconds < 60 else { return nil }
|
|
return components[0] * 60 + seconds
|
|
case 3: // H:MM:SS
|
|
let minutes = components[1]
|
|
let seconds = components[2]
|
|
guard minutes < 60, seconds < 60 else { return nil }
|
|
return components[0] * 3600 + minutes * 60 + seconds
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// URL scheme used for seek timestamps.
|
|
static let seekScheme = "yattee-seek"
|
|
|
|
/// Extracts the seconds value from a seek URL, if valid.
|
|
static func seekSeconds(from url: URL) -> Int? {
|
|
guard url.scheme == seekScheme else { return nil }
|
|
return Int(url.host ?? "")
|
|
}
|
|
}
|
|
|
|
// MARK: - OpenURL Action for Seeking
|
|
|
|
/// Opens `url` in the user's default system browser (Safari on iOS/tvOS,
|
|
/// default browser on macOS). Used as the fallback when short-link resolution
|
|
/// fails or the destination isn't a URL the app can handle.
|
|
@MainActor
|
|
private func openInSystemBrowser(_ url: URL) {
|
|
#if os(iOS)
|
|
UIApplication.shared.open(url)
|
|
#elseif os(macOS)
|
|
NSWorkspace.shared.open(url)
|
|
#endif
|
|
// tvOS has no system browser and no way to tap description links in the
|
|
// first place — this path is unreachable there.
|
|
}
|
|
|
|
extension View {
|
|
/// Adds a URL handler that intercepts timestamp links (seeks the player) and
|
|
/// known content URLs — YouTube/PeerTube video/channel/playlist links and external
|
|
/// video URLs — so they open in-app instead of the browser.
|
|
///
|
|
/// When the user has enabled "Resolve Short Links" in YouTube Enhancements,
|
|
/// taps on known URL shorteners (bit.ly, tinyurl, t.co, …) whose hosts aren't
|
|
/// themselves routable are resolved asynchronously: if the redirect target is
|
|
/// a supported URL, it's opened in-app; otherwise we fall back to the system
|
|
/// browser with the original URL.
|
|
func handleTimestampLinks(using playerService: PlayerService?) -> some View {
|
|
self.modifier(HandleTimestampLinksModifier(playerService: playerService))
|
|
}
|
|
}
|
|
|
|
private struct HandleTimestampLinksModifier: ViewModifier {
|
|
let playerService: PlayerService?
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
func body(content: Content) -> some View {
|
|
let resolveShortLinks = appEnvironment?.settingsManager.resolveShortLinksEnabled ?? false
|
|
return content.environment(\.openURL, OpenURLAction { url in
|
|
if let seconds = DescriptionText.seekSeconds(from: url) {
|
|
Task {
|
|
await playerService?.seek(to: TimeInterval(seconds))
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
let router = URLRouter()
|
|
|
|
// 1. Definitely-playable URLs (YouTube / PeerTube / direct media /
|
|
// custom scheme) open in-app without any prompt.
|
|
if router.routeConfidently(url) != nil {
|
|
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
|
|
return .handled
|
|
}
|
|
|
|
// 2. URL shorteners — resolve first, *then* decide. This has to run
|
|
// before the loose `route()` check below, because `route()` would
|
|
// otherwise match bit.ly/t.co/etc. as `.externalVideo` and send
|
|
// the *shortener* URL itself to yt-dlp.
|
|
if resolveShortLinks && URLShortenerResolver.isShortener(url) {
|
|
Task { @MainActor in
|
|
guard let resolved = await URLShortenerResolver.resolve(url) else {
|
|
openInSystemBrowser(url)
|
|
return
|
|
}
|
|
|
|
if router.routeConfidently(resolved) != nil {
|
|
NotificationCenter.default.post(name: .openDescriptionLink, object: resolved)
|
|
} else {
|
|
// Ambiguous destination (e.g. a news article). Let the user
|
|
// decide whether to try opening in Yattee (falls back to
|
|
// yt-dlp extraction) or in the system browser.
|
|
NotificationCenter.default.post(name: .promptResolvedShortLink, object: resolved)
|
|
}
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
// 3. Non-shortener URLs that only match via the loose `.externalVideo`
|
|
// fallback (e.g. vimeo.com/…, news articles, any http(s)): we
|
|
// can't tell whether yt-dlp will successfully extract, so ask
|
|
// the user whether to try extracting or open in the browser.
|
|
if router.routeConfidently(url) == nil, router.route(url) != nil {
|
|
NotificationCenter.default.post(name: .promptAmbiguousExternalLink, object: url)
|
|
return .handled
|
|
}
|
|
|
|
return .systemAction
|
|
})
|
|
}
|
|
}
|