mirror of
				https://github.com/yattee/yattee.git
				synced 2025-11-04 06:32:03 +00:00 
			
		
		
		
	@@ -655,7 +655,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
 | 
			
		||||
                kind: .adaptive,
 | 
			
		||||
                encoding: videoStream["encoding"].string,
 | 
			
		||||
                videoFormat: videoStream["type"].string,
 | 
			
		||||
                bitrate: videoStream["bitrate"].int
 | 
			
		||||
                bitrate: videoStream["bitrate"].int,
 | 
			
		||||
                requestRange: videoStream["init"].string ?? videoStream["index"].string
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -491,6 +491,35 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
 | 
			
		||||
        guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
 | 
			
		||||
            completion(asset)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
 | 
			
		||||
              let hostValue = hostItem.value
 | 
			
		||||
        else {
 | 
			
		||||
            completion(asset)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        urlComponents.host = hostValue
 | 
			
		||||
 | 
			
		||||
        guard let newUrl = urlComponents.url else {
 | 
			
		||||
            completion(asset)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        completion(AVURLAsset(url: newUrl))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Overload used for hlsURLS
 | 
			
		||||
    static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
 | 
			
		||||
        let asset = AVURLAsset(url: url)
 | 
			
		||||
        nonProxiedAsset(asset: asset, completion: completion)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func extractVideo(from content: JSON) -> Video? {
 | 
			
		||||
        let details = content.dictionaryValue
 | 
			
		||||
 | 
			
		||||
@@ -579,10 +608,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
 | 
			
		||||
            return nil
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return URL(string: thumbnailURL
 | 
			
		||||
            .absoluteString
 | 
			
		||||
            .replacingOccurrences(of: "hqdefault", with: quality.filename)
 | 
			
		||||
            .replacingOccurrences(of: "maxresdefault", with: quality.filename)
 | 
			
		||||
        return URL(
 | 
			
		||||
            string: thumbnailURL
 | 
			
		||||
                .absoluteString
 | 
			
		||||
                .replacingOccurrences(of: "hqdefault", with: quality.filename)
 | 
			
		||||
                .replacingOccurrences(of: "maxresdefault", with: quality.filename)
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -688,6 +718,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
 | 
			
		||||
            let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
 | 
			
		||||
            let videoFormat = videoStream.dictionaryValue["format"]?.string
 | 
			
		||||
            let bitrate = videoStream.dictionaryValue["bitrate"]?.int
 | 
			
		||||
            var requestRange: String?
 | 
			
		||||
 | 
			
		||||
            if let initStart = videoStream.dictionaryValue["initStart"]?.int,
 | 
			
		||||
               let initEnd = videoStream.dictionaryValue["initEnd"]?.int
 | 
			
		||||
            {
 | 
			
		||||
                requestRange = "\(initStart)-\(initEnd)"
 | 
			
		||||
            } else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
 | 
			
		||||
                      let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
 | 
			
		||||
            {
 | 
			
		||||
                requestRange = "\(indexStart)-\(indexEnd)"
 | 
			
		||||
            } else {
 | 
			
		||||
                requestRange = nil
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if videoOnly {
 | 
			
		||||
                streams.append(
 | 
			
		||||
@@ -698,7 +741,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
 | 
			
		||||
                        resolution: resolution,
 | 
			
		||||
                        kind: .adaptive,
 | 
			
		||||
                        videoFormat: videoFormat,
 | 
			
		||||
                        bitrate: bitrate
 | 
			
		||||
                        bitrate: bitrate,
 | 
			
		||||
                        requestRange: requestRange
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
            } else {
 | 
			
		||||
 
 | 
			
		||||
@@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var allowsDisablingVidoesProxying: Bool {
 | 
			
		||||
        self == .invidious
 | 
			
		||||
        self == .invidious || self == .piped
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var supportsOpeningVideosByID: Bool {
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var bufferingStateText: String? {
 | 
			
		||||
        guard detailsAvailable else { return nil }
 | 
			
		||||
        guard detailsAvailable && player.hasStarted else { return nil }
 | 
			
		||||
        return String(format: "%.0f%%", bufferingState)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,11 @@ final class AVPlayerBackend: PlayerBackend {
 | 
			
		||||
 | 
			
		||||
    var isLoadingVideo = false
 | 
			
		||||
 | 
			
		||||
    var hasStarted = false
 | 
			
		||||
    var isPaused: Bool {
 | 
			
		||||
        avPlayer.timeControlStatus == .paused
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var isPlaying: Bool {
 | 
			
		||||
        avPlayer.timeControlStatus == .playing
 | 
			
		||||
    }
 | 
			
		||||
@@ -158,6 +163,12 @@ final class AVPlayerBackend: PlayerBackend {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        avPlayer.play()
 | 
			
		||||
 | 
			
		||||
        // Setting hasStarted to true the first time player started
 | 
			
		||||
        if !hasStarted {
 | 
			
		||||
            hasStarted = true
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        model.objectWillChange.send()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -180,6 +191,7 @@ final class AVPlayerBackend: PlayerBackend {
 | 
			
		||||
 | 
			
		||||
    func stop() {
 | 
			
		||||
        avPlayer.replaceCurrentItem(with: nil)
 | 
			
		||||
        hasStarted = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func cancelLoads() {
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,8 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
        }
 | 
			
		||||
    }}
 | 
			
		||||
 | 
			
		||||
    var hasStarted = false
 | 
			
		||||
    var isPaused = false
 | 
			
		||||
    var isPlaying = true { didSet {
 | 
			
		||||
        networkStateTimer.start()
 | 
			
		||||
 | 
			
		||||
@@ -337,7 +339,6 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func play() {
 | 
			
		||||
        isPlaying = true
 | 
			
		||||
        startClientUpdates()
 | 
			
		||||
 | 
			
		||||
        if controls.presentingControls {
 | 
			
		||||
@@ -354,13 +355,22 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        client?.play()
 | 
			
		||||
 | 
			
		||||
        isPlaying = true
 | 
			
		||||
        isPaused = false
 | 
			
		||||
 | 
			
		||||
        // Setting hasStarted to true the first time player started
 | 
			
		||||
        if !hasStarted {
 | 
			
		||||
            hasStarted = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func pause() {
 | 
			
		||||
        isPlaying = false
 | 
			
		||||
        stopClientUpdates()
 | 
			
		||||
 | 
			
		||||
        client?.pause()
 | 
			
		||||
        isPaused = true
 | 
			
		||||
        isPlaying = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func togglePlay() {
 | 
			
		||||
@@ -377,6 +387,9 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
 | 
			
		||||
    func stop() {
 | 
			
		||||
        client?.stop()
 | 
			
		||||
        isPlaying = false
 | 
			
		||||
        isPaused = false
 | 
			
		||||
        hasStarted = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
 | 
			
		||||
@@ -392,8 +405,8 @@ final class MPVBackend: PlayerBackend {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func closeItem() {
 | 
			
		||||
        client?.pause()
 | 
			
		||||
        client?.stop()
 | 
			
		||||
        pause()
 | 
			
		||||
        stop()
 | 
			
		||||
        self.video = nil
 | 
			
		||||
        self.stream = nil
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,8 @@ protocol PlayerBackend {
 | 
			
		||||
    var loadedVideo: Bool { get }
 | 
			
		||||
    var isLoadingVideo: Bool { get }
 | 
			
		||||
 | 
			
		||||
    var hasStarted: Bool { get }
 | 
			
		||||
    var isPaused: Bool { get }
 | 
			
		||||
    var isPlaying: Bool { get }
 | 
			
		||||
    var isSeeking: Bool { get }
 | 
			
		||||
    var playerItemDuration: CMTime? { get }
 | 
			
		||||
 
 | 
			
		||||
@@ -298,6 +298,14 @@ final class PlayerModel: ObservableObject {
 | 
			
		||||
        backend.isPlaying
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var isPaused: Bool {
 | 
			
		||||
        backend.isPaused
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var hasStarted: Bool {
 | 
			
		||||
        backend.hasStarted
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var playerItemDuration: CMTime? {
 | 
			
		||||
        guard !currentItem.isNil else {
 | 
			
		||||
            return nil
 | 
			
		||||
 
 | 
			
		||||
@@ -74,7 +74,7 @@ extension PlayerModel {
 | 
			
		||||
        preservedTime = currentItem.playbackTime
 | 
			
		||||
 | 
			
		||||
        DispatchQueue.main.async { [weak self] in
 | 
			
		||||
            guard let self else { return }
 | 
			
		||||
            guard let self = self else { return }
 | 
			
		||||
            guard let video = item.video else {
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
@@ -94,7 +94,9 @@ extension PlayerModel {
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                self.videoBeingOpened = nil
 | 
			
		||||
                self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
 | 
			
		||||
                self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
 | 
			
		||||
                    self.availableStreams = processedStreams
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import AVFoundation
 | 
			
		||||
import Foundation
 | 
			
		||||
import Siesta
 | 
			
		||||
import SwiftUI
 | 
			
		||||
@@ -41,7 +42,9 @@ extension PlayerModel {
 | 
			
		||||
                        self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
                    self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
 | 
			
		||||
                    self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
 | 
			
		||||
                        self.availableStreams = processedStreams
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    self.logger.critical("no streams available from \(instance.description)")
 | 
			
		||||
                }
 | 
			
		||||
@@ -53,20 +56,143 @@ extension PlayerModel {
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
 | 
			
		||||
        streams.map { stream in
 | 
			
		||||
            stream.instance = instance
 | 
			
		||||
    func streamsWithInstance(instance _: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
 | 
			
		||||
        // Queue for stream processing
 | 
			
		||||
        let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue", qos: .userInitiated)
 | 
			
		||||
        // Queue for accessing the processedStreams array
 | 
			
		||||
        let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
 | 
			
		||||
        // DispatchGroup for managing multiple tasks
 | 
			
		||||
        let streamProcessingGroup = DispatchGroup()
 | 
			
		||||
 | 
			
		||||
            if instance.app == .invidious, instance.proxiesVideos {
 | 
			
		||||
                if let audio = stream.audioAsset {
 | 
			
		||||
                    stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
 | 
			
		||||
        var processedStreams = [Stream]()
 | 
			
		||||
 | 
			
		||||
        for stream in streams {
 | 
			
		||||
            streamProcessingQueue.async(group: streamProcessingGroup) {
 | 
			
		||||
                let forbiddenAssetTestGroup = DispatchGroup()
 | 
			
		||||
                var hasForbiddenAsset = false
 | 
			
		||||
 | 
			
		||||
                let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
 | 
			
		||||
 | 
			
		||||
                if let randomStream = nonHLSAssets.randomElement() {
 | 
			
		||||
                    let instance = randomStream.0
 | 
			
		||||
                    let asset = randomStream.1
 | 
			
		||||
                    let url = randomStream.2
 | 
			
		||||
                    let requestRange = randomStream.3
 | 
			
		||||
 | 
			
		||||
                    // swiftlint:disable:next shorthand_optional_binding
 | 
			
		||||
                    if let asset = asset, let instance = instance, !instance.proxiesVideos {
 | 
			
		||||
                        if instance.app == .invidious {
 | 
			
		||||
                            self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
 | 
			
		||||
                                hasForbiddenAsset = isForbidden
 | 
			
		||||
                            }
 | 
			
		||||
                        } else if instance.app == .piped {
 | 
			
		||||
                            self.testPipedAssets(asset: asset, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
 | 
			
		||||
                                hasForbiddenAsset = isForbidden
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } else if let randomHLS = hlsURLs.randomElement() {
 | 
			
		||||
                    let instance = randomHLS.0
 | 
			
		||||
                    let asset = AVURLAsset(url: randomHLS.1)
 | 
			
		||||
 | 
			
		||||
                    if instance?.app == .piped {
 | 
			
		||||
                        self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { isForbidden in
 | 
			
		||||
                            hasForbiddenAsset = isForbidden
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if let video = stream.videoAsset {
 | 
			
		||||
                    stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
 | 
			
		||||
 | 
			
		||||
                forbiddenAssetTestGroup.wait()
 | 
			
		||||
 | 
			
		||||
                // Post-processing code
 | 
			
		||||
                if let instance = stream.instance {
 | 
			
		||||
                    if instance.app == .invidious {
 | 
			
		||||
                        if hasForbiddenAsset || instance.proxiesVideos {
 | 
			
		||||
                            if let audio = stream.audioAsset {
 | 
			
		||||
                                stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
 | 
			
		||||
                            }
 | 
			
		||||
                            if let video = stream.videoAsset {
 | 
			
		||||
                                stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    } else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
 | 
			
		||||
                        if let hlsURL = stream.hlsURL {
 | 
			
		||||
                            PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
 | 
			
		||||
                                if let nonProxiedURL = possibleNonProxiedURL {
 | 
			
		||||
                                    stream.hlsURL = nonProxiedURL.url
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            if let audio = stream.audioAsset {
 | 
			
		||||
                                PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
 | 
			
		||||
                                    stream.audioAsset = nonProxiedAudioAsset
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            if let video = stream.videoAsset {
 | 
			
		||||
                                PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
 | 
			
		||||
                                    stream.videoAsset = nonProxiedVideoAsset
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Append to processedStreams within the processedStreamsQueue
 | 
			
		||||
                processedStreamsQueue.sync {
 | 
			
		||||
                    processedStreams.append(stream)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
            return stream
 | 
			
		||||
        streamProcessingGroup.notify(queue: .main) {
 | 
			
		||||
            // Access and pass processedStreams within the processedStreamsQueue block
 | 
			
		||||
            processedStreamsQueue.sync {
 | 
			
		||||
                completion(processedStreams)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(Instance?, AVURLAsset?, URL, String?)], hlsURLs: [(Instance?, URL)]) {
 | 
			
		||||
        var nonHLSAssets = [(Instance?, AVURLAsset?, URL, String?)]()
 | 
			
		||||
        var hlsURLs = [(Instance?, URL)]()
 | 
			
		||||
 | 
			
		||||
        for stream in streams {
 | 
			
		||||
            if stream.isHLS {
 | 
			
		||||
                if let url = stream.hlsURL?.url {
 | 
			
		||||
                    hlsURLs.append((stream.instance, url))
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                if let asset = stream.audioAsset {
 | 
			
		||||
                    nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
 | 
			
		||||
                }
 | 
			
		||||
                if let asset = stream.videoAsset {
 | 
			
		||||
                    nonHLSAssets.append((stream.instance, asset, asset.url, stream.requestRange))
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (nonHLSAssets, hlsURLs)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
 | 
			
		||||
        // In case the range is nil, generate a random one.
 | 
			
		||||
        let randomEnd = Int.random(in: 200 ... 800)
 | 
			
		||||
        let requestRange = range ?? "0-\(randomEnd)"
 | 
			
		||||
 | 
			
		||||
        forbiddenAssetTestGroup.enter()
 | 
			
		||||
        URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
 | 
			
		||||
            completion(statusCode == HTTPStatus.Forbidden)
 | 
			
		||||
            forbiddenAssetTestGroup.leave()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Bool) -> Void) {
 | 
			
		||||
        PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
 | 
			
		||||
            if let nonProxiedAsset = possibleNonProxiedAsset {
 | 
			
		||||
                self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
 | 
			
		||||
            } else {
 | 
			
		||||
                completion(false)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -170,6 +170,7 @@ class Stream: Equatable, Hashable, Identifiable {
 | 
			
		||||
    var encoding: String?
 | 
			
		||||
    var videoFormat: String?
 | 
			
		||||
    var bitrate: Int?
 | 
			
		||||
    var requestRange: String?
 | 
			
		||||
 | 
			
		||||
    init(
 | 
			
		||||
        instance: Instance? = nil,
 | 
			
		||||
@@ -181,7 +182,8 @@ class Stream: Equatable, Hashable, Identifiable {
 | 
			
		||||
        kind: Kind = .hls,
 | 
			
		||||
        encoding: String? = nil,
 | 
			
		||||
        videoFormat: String? = nil,
 | 
			
		||||
        bitrate: Int? = nil
 | 
			
		||||
        bitrate: Int? = nil,
 | 
			
		||||
        requestRange: String? = nil
 | 
			
		||||
    ) {
 | 
			
		||||
        self.instance = instance
 | 
			
		||||
        self.audioAsset = audioAsset
 | 
			
		||||
@@ -193,6 +195,7 @@ class Stream: Equatable, Hashable, Identifiable {
 | 
			
		||||
        self.encoding = encoding
 | 
			
		||||
        format = .from(videoFormat ?? "")
 | 
			
		||||
        self.bitrate = bitrate
 | 
			
		||||
        self.requestRange = requestRange
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var isLocal: Bool {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@
 | 
			
		||||
<dict>
 | 
			
		||||
	<key>com.apple.security.application-groups</key>
 | 
			
		||||
	<array>
 | 
			
		||||
		<string>group.78Z5H3M6RJ.stream.yattee.app.urlbookmarks</string>
 | 
			
		||||
		<string>group.stonerl.yattee.app.url</string>
 | 
			
		||||
	</array>
 | 
			
		||||
</dict>
 | 
			
		||||
</plist>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										81
									
								
								Shared/HTTPStatus.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								Shared/HTTPStatus.swift
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
// HTTP response status codes
 | 
			
		||||
 | 
			
		||||
enum HTTPStatus {
 | 
			
		||||
    // Informational responses (100 - 199)
 | 
			
		||||
 | 
			
		||||
    static let Continue = 100
 | 
			
		||||
    static let SwitchingProtocols = 101
 | 
			
		||||
    static let Processing = 102
 | 
			
		||||
    static let EarlyHints = 103
 | 
			
		||||
 | 
			
		||||
    // Successful responses (200 - 299)
 | 
			
		||||
 | 
			
		||||
    static let OK = 200
 | 
			
		||||
    static let Created = 201
 | 
			
		||||
    static let Accepted = 202
 | 
			
		||||
    static let NonAuthoritativeInformation = 203
 | 
			
		||||
    static let NoContent = 204
 | 
			
		||||
    static let ResetContent = 205
 | 
			
		||||
    static let PartialContent = 206
 | 
			
		||||
    static let MultiStatus = 207
 | 
			
		||||
    static let AlreadyReported = 208
 | 
			
		||||
    static let IMUsed = 226
 | 
			
		||||
 | 
			
		||||
    // Redirection messages (300 - 399)
 | 
			
		||||
 | 
			
		||||
    static let MultipleChoices = 300
 | 
			
		||||
    static let MovedPermanently = 301
 | 
			
		||||
    static let Found = 302
 | 
			
		||||
    static let SeeOther = 303
 | 
			
		||||
    static let NotModified = 304
 | 
			
		||||
    static let UseProxy = 305
 | 
			
		||||
    static let SwitchProxy = 306
 | 
			
		||||
    static let TemporaryRedirect = 307
 | 
			
		||||
    static let PermanentRedirect = 308
 | 
			
		||||
 | 
			
		||||
    // Client error responses (400 - 499)
 | 
			
		||||
 | 
			
		||||
    static let BadRequest = 400
 | 
			
		||||
    static let Unauthorized = 401
 | 
			
		||||
    static let PaymentRequired = 402
 | 
			
		||||
    static let Forbidden = 403
 | 
			
		||||
    static let NotFound = 404
 | 
			
		||||
    static let MethodNotAllowed = 405
 | 
			
		||||
    static let NotAcceptable = 406
 | 
			
		||||
    static let ProxyAuthenticationRequired = 407
 | 
			
		||||
    static let RequestTimeout = 408
 | 
			
		||||
    static let Conflict = 409
 | 
			
		||||
    static let Gone = 410
 | 
			
		||||
    static let LengthRequired = 411
 | 
			
		||||
    static let PreconditionFailed = 412
 | 
			
		||||
    static let PayloadTooLarge = 413
 | 
			
		||||
    static let URITooLong = 414
 | 
			
		||||
    static let UnsupportedMediaType = 415
 | 
			
		||||
    static let RangeNotSatisfiable = 416
 | 
			
		||||
    static let ExpectationFailed = 417
 | 
			
		||||
    static let IAmATeapot = 418
 | 
			
		||||
    static let MisdirectedRequest = 421
 | 
			
		||||
    static let UnprocessableEntity = 422
 | 
			
		||||
    static let Locked = 423
 | 
			
		||||
    static let FailedDependency = 424
 | 
			
		||||
    static let TooEarly = 425
 | 
			
		||||
    static let UpgradeRequired = 426
 | 
			
		||||
    static let PreconditionRequired = 428
 | 
			
		||||
    static let TooManyRequests = 429
 | 
			
		||||
    static let RequestHeaderFieldsTooLarge = 431
 | 
			
		||||
    static let UnavailableForLegalReasons = 451
 | 
			
		||||
 | 
			
		||||
    // Server error responses (500 - 599)
 | 
			
		||||
 | 
			
		||||
    static let InternalServerError = 500
 | 
			
		||||
    static let NotImplemented = 501
 | 
			
		||||
    static let BadGateway = 502
 | 
			
		||||
    static let ServiceUnavailable = 503
 | 
			
		||||
    static let GatewayTimeout = 504
 | 
			
		||||
    static let HTTPVersionNotSupported = 505
 | 
			
		||||
    static let VariantAlsoNegotiates = 506
 | 
			
		||||
    static let InsufficientStorage = 507
 | 
			
		||||
    static let LoopDetected = 508
 | 
			
		||||
    static let NotExtended = 510
 | 
			
		||||
    static let NetworkAuthenticationRequired = 511
 | 
			
		||||
}
 | 
			
		||||
@@ -10,26 +10,28 @@ struct OpeningStream: View {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var visible: Bool {
 | 
			
		||||
        (!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
 | 
			
		||||
        (!player.currentItem.isNil && !player.videoBeingOpened.isNil) ||
 | 
			
		||||
            (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking) ||
 | 
			
		||||
            !player.hasStarted
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var reason: String {
 | 
			
		||||
        guard player.videoBeingOpened == nil else {
 | 
			
		||||
            return "Loading streams...".localized()
 | 
			
		||||
            return "Loading streams…".localized()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if player.musicMode {
 | 
			
		||||
            return "Opening audio stream...".localized()
 | 
			
		||||
            return "Opening audio stream…".localized()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if let selection = player.streamSelection {
 | 
			
		||||
            if selection.isLocal {
 | 
			
		||||
                return "Opening file...".localized()
 | 
			
		||||
                return "Opening file…".localized()
 | 
			
		||||
            }
 | 
			
		||||
            return String(format: "Opening %@ stream...".localized(), selection.shortQuality)
 | 
			
		||||
            return String(format: "Opening %@ stream…".localized(), selection.shortQuality)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return "Loading streams...".localized()
 | 
			
		||||
        return "Loading streams…".localized()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var state: String? {
 | 
			
		||||
 
 | 
			
		||||
@@ -75,16 +75,20 @@ struct PlayerControls: View {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            VStack {
 | 
			
		||||
                Spacer()
 | 
			
		||||
                ZStack {
 | 
			
		||||
                    VStack(spacing: 0) {
 | 
			
		||||
                        ZStack {
 | 
			
		||||
                            OpeningStream()
 | 
			
		||||
                            NetworkState()
 | 
			
		||||
                    GeometryReader { geometry in
 | 
			
		||||
                        VStack(spacing: 0) {
 | 
			
		||||
                            ZStack {
 | 
			
		||||
                                OpeningStream()
 | 
			
		||||
                                NetworkState()
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        Spacer()
 | 
			
		||||
                        .position(
 | 
			
		||||
                            x: geometry.size.width / 2,
 | 
			
		||||
                            y: geometry.size.height / 2
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                    .offset(y: playerControlsLayout.osdVerticalOffset + 5)
 | 
			
		||||
 | 
			
		||||
                    if showControls {
 | 
			
		||||
                        Section {
 | 
			
		||||
 
 | 
			
		||||
@@ -278,10 +278,6 @@ enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var osdVerticalOffset: Double {
 | 
			
		||||
        buttonSize
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var osdProgressBarHeight: Double {
 | 
			
		||||
        switch self {
 | 
			
		||||
        case .tvRegular:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										109
									
								
								Shared/URLTester.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								Shared/URLTester.swift
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,109 @@
 | 
			
		||||
import Foundation
 | 
			
		||||
import Logging
 | 
			
		||||
 | 
			
		||||
enum URLTester {
 | 
			
		||||
    private static let hlsMediaPrefix = "#EXT-X-MEDIA:"
 | 
			
		||||
    private static let hlsInfPrefix = "#EXTINF:"
 | 
			
		||||
    private static let uriRegex = "(?<=URI=\")(.*?)(?=\")"
 | 
			
		||||
 | 
			
		||||
    static func testURLResponse(url: URL, range: String, isHLS: Bool, completion: @escaping (Int) -> Void) {
 | 
			
		||||
        if isHLS {
 | 
			
		||||
            parseAndTestHLSManifest(manifestUrl: url, range: range, completion: completion)
 | 
			
		||||
        } else {
 | 
			
		||||
            httpRequest(url: url, range: range) { statusCode, _ in
 | 
			
		||||
                completion(statusCode)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func httpRequest(url: URL, range: String, completion: @escaping (Int, URLSessionDataTask?) -> Void) {
 | 
			
		||||
        var request = URLRequest(url: url)
 | 
			
		||||
        request.httpMethod = "HEAD"
 | 
			
		||||
        request.setValue("bytes=\(range)", forHTTPHeaderField: "Range")
 | 
			
		||||
 | 
			
		||||
        var dataTask: URLSessionDataTask?
 | 
			
		||||
        dataTask = URLSession.shared.dataTask(with: request) { _, response, _ in
 | 
			
		||||
            let statusCode = (response as? HTTPURLResponse)?.statusCode ?? HTTPStatus.Forbidden
 | 
			
		||||
            Logger(label: "stream.yattee.httpRequest").info("URL: \(url) | Status Code: \(statusCode)")
 | 
			
		||||
            completion(statusCode, dataTask)
 | 
			
		||||
        }
 | 
			
		||||
        dataTask?.resume()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static func parseAndTestHLSManifest(manifestUrl: URL, range: String, completion: @escaping (Int) -> Void) {
 | 
			
		||||
        recursivelyParseManifest(manifestUrl: manifestUrl) { allURLs in
 | 
			
		||||
            if let url = allURLs.randomElement() {
 | 
			
		||||
                httpRequest(url: url, range: range) { statusCode, _ in
 | 
			
		||||
                    completion(statusCode)
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                completion(HTTPStatus.NotFound)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func recursivelyParseManifest(manifestUrl: URL, fullyParsed: @escaping ([URL]) -> Void) {
 | 
			
		||||
        parseHLSManifest(manifestUrl: manifestUrl) { urls in
 | 
			
		||||
            var allURLs = [URL]()
 | 
			
		||||
            let group = DispatchGroup()
 | 
			
		||||
            for url in urls {
 | 
			
		||||
                if url.pathExtension == "m3u8" {
 | 
			
		||||
                    group.enter()
 | 
			
		||||
                    recursivelyParseManifest(manifestUrl: url) { subUrls in
 | 
			
		||||
                        allURLs += subUrls
 | 
			
		||||
                        group.leave()
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    allURLs.append(url)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            group.notify(queue: .main) {
 | 
			
		||||
                fullyParsed(allURLs)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func parseHLSManifest(manifestUrl: URL, completion: @escaping ([URL]) -> Void) {
 | 
			
		||||
        URLSession.shared.dataTask(with: manifestUrl) { data, _, _ in
 | 
			
		||||
            // swiftlint:disable:next shorthand_optional_binding
 | 
			
		||||
            guard let data = data else {
 | 
			
		||||
                Logger(label: "stream.yattee.httpRequest").error("Data is nil")
 | 
			
		||||
                completion([])
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // swiftlint:disable:next non_optional_string_data_conversion
 | 
			
		||||
            guard let manifest = String(data: data, encoding: .utf8), !manifest.isEmpty else {
 | 
			
		||||
                Logger(label: "stream.yattee.httpRequest").error("Cannot read or empty HLS manifest")
 | 
			
		||||
                completion([])
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let lines = manifest.split(separator: "\n")
 | 
			
		||||
            var mediaURLs: [URL] = []
 | 
			
		||||
 | 
			
		||||
            for index in 0 ..< lines.count {
 | 
			
		||||
                let lineString = String(lines[index])
 | 
			
		||||
 | 
			
		||||
                if lineString.hasPrefix(hlsMediaPrefix),
 | 
			
		||||
                   let uriRange = lineString.range(of: uriRegex, options: .regularExpression)
 | 
			
		||||
                {
 | 
			
		||||
                    let uri = lineString[uriRange]
 | 
			
		||||
                    if let url = URL(string: String(uri)) {
 | 
			
		||||
                        mediaURLs.append(url)
 | 
			
		||||
                    }
 | 
			
		||||
                } else if lineString.hasPrefix(hlsInfPrefix), index < lines.count - 1 {
 | 
			
		||||
                    let possibleURL = String(lines[index + 1])
 | 
			
		||||
                    let baseURL = manifestUrl.deletingLastPathComponent()
 | 
			
		||||
                    if let relativeURL = URL(string: possibleURL, relativeTo: baseURL),
 | 
			
		||||
                       relativeURL.scheme != nil
 | 
			
		||||
                    {
 | 
			
		||||
                        mediaURLs.append(relativeURL)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            completion(mediaURLs)
 | 
			
		||||
        }
 | 
			
		||||
        .resume()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -129,7 +129,7 @@
 | 
			
		||||
"LIVE" = "مباشر";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "تحميل بثوث...";
 | 
			
		||||
"Loading streams…" = "تحميل بثوث…";
 | 
			
		||||
"Loading..." = "تحميل...";
 | 
			
		||||
 | 
			
		||||
/* Video duration filter in search */
 | 
			
		||||
@@ -163,8 +163,8 @@
 | 
			
		||||
"Open Settings" = "فتح الإعدادات";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "فتح بث %@ ...";
 | 
			
		||||
"Opening audio stream..." = "فتح بث صوتي...";
 | 
			
		||||
"Opening %@ stream…" = "فتح بث %@ …";
 | 
			
		||||
"Opening audio stream…" = "فتح بث صوتي…";
 | 
			
		||||
"Orientation" = "اتجاه";
 | 
			
		||||
"Play in PiP" = "تشغيل في الفيديو المصغر";
 | 
			
		||||
"Play Last" = "تشغيل الأخير";
 | 
			
		||||
@@ -558,7 +558,7 @@
 | 
			
		||||
"Are you sure you want to clear cache?" = "هل أنت متأكد من أنك تريد مسح ذاكرة التخزين المؤقت؟";
 | 
			
		||||
"Gesture settings control skipping interval for remote arrow buttons (for 2nd generation Siri Remote or newer). Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة الفاصل الزمني للتخطي لأزرار الأسهم عن بعد (للجيل الثاني من Siri Remote أو أحدث). تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
 | 
			
		||||
"Opened File" = "ملف مفتوح";
 | 
			
		||||
"Opening file..." = "فتح الملف...";
 | 
			
		||||
"Opening file…" = "فتح الملف…";
 | 
			
		||||
"Short videos: hidden" = "مقاطع الفيديو القصيرة: مخفية";
 | 
			
		||||
"Mark channel feed as watched" = "وضع علامة تمت المشاهدة على محتوى القناة";
 | 
			
		||||
"Gesture settings control skipping interval for double click on left/right side of the player. Changing system controls settings requires restart." = "التحكم في إعدادات إيماءة فترة التخطي للنقر المزدوج على الجانب الأيسر/الأيمن من المشغل. تغيير إعدادات نظام عناصر التحكم يتطلب إعادة بدء التشغيل.";
 | 
			
		||||
 
 | 
			
		||||
@@ -277,11 +277,11 @@
 | 
			
		||||
"Large" = "Böyük";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Yayımlar yüklənilir...";
 | 
			
		||||
"Loading streams…" = "Yayımlar yüklənilir…";
 | 
			
		||||
"Only when signed in" = "Yalnız daxil olduqda";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "%@ yayımı açılır...";
 | 
			
		||||
"Opening %@ stream…" = "%@ yayımı açılır…";
 | 
			
		||||
"Matrix Chat" = "Matrix Söhbət";
 | 
			
		||||
 | 
			
		||||
/* Player controls layout size */
 | 
			
		||||
@@ -296,7 +296,7 @@
 | 
			
		||||
"Open Settings" = "Tənzimləmələri Aç";
 | 
			
		||||
"Movies" = "Filmlər";
 | 
			
		||||
"No description" = "Açıqlama yoxdur";
 | 
			
		||||
"Opening audio stream..." = "Səs yayımı açılır...";
 | 
			
		||||
"Opening audio stream…" = "Səs yayımı açılır…";
 | 
			
		||||
"Password" = "Şifrə";
 | 
			
		||||
"Preferred Formats" = "Üstünlük Verilən Formatlar";
 | 
			
		||||
"Quality Profile" = "Profil Keyfiyyəti";
 | 
			
		||||
 
 | 
			
		||||
@@ -113,7 +113,7 @@
 | 
			
		||||
"LIVE" = "EN VIU";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "S'estan carregant els fluxos...";
 | 
			
		||||
"Loading streams…" = "S'estan carregant els fluxos…";
 | 
			
		||||
"Loading..." = "Carregant...";
 | 
			
		||||
"Locations" = "Ubicacions";
 | 
			
		||||
"Lock portrait mode" = "Bloqueja el mode vertical";
 | 
			
		||||
@@ -147,7 +147,7 @@
 | 
			
		||||
"Offtopic in Music Videos" = "Offtopic als vídeos musicals";
 | 
			
		||||
"Open \"Playlists\" tab to create new one" = "Obriu la pestanya \"Llistes de reproducció\" per crear-ne una de nova";
 | 
			
		||||
"Open Settings" = "Obriu Configuració";
 | 
			
		||||
"Opening audio stream..." = "Obrint la reproducció d'àudio...";
 | 
			
		||||
"Opening audio stream…" = "Obrint la reproducció d'àudio…";
 | 
			
		||||
"Orientation" = "Orientació";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -454,7 +454,7 @@
 | 
			
		||||
"Low" = "Baix";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "S'està obrint %@...";
 | 
			
		||||
"Opening %@ stream…" = "S'està obrint %@…";
 | 
			
		||||
"Only when signed in" = "Només quan s'ha iniciat la sessió";
 | 
			
		||||
"Password" = "Contrasenya";
 | 
			
		||||
"Part of a video promoting a product or service not directly related to the creator. The creator will receive payment or compensation in the form of money or free products." = "Part d'un vídeo que promociona un producte o servei no relacionat directament amb el creador. El creador rebrà un pagament o compensació en forma de diners o productes gratuïts.";
 | 
			
		||||
 
 | 
			
		||||
@@ -166,7 +166,7 @@
 | 
			
		||||
"Only when signed in" = "Pouze když přihlášený";
 | 
			
		||||
"Open \"Playlists\" tab to create new one" = "Otevřete kartu \"Playlisty\", aby jste vytvořili nový";
 | 
			
		||||
"Open Settings" = "Otevřete Nastavení";
 | 
			
		||||
"Opening audio stream..." = "Otevírám audio stream...";
 | 
			
		||||
"Opening audio stream…" = "Otevírám audio stream…";
 | 
			
		||||
"Orientation" = "Orientace";
 | 
			
		||||
"Password" = "Heslo";
 | 
			
		||||
"Pause" = "Pauza";
 | 
			
		||||
@@ -401,11 +401,11 @@
 | 
			
		||||
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Propagace produktu nebo služby, která přímo souvisí s tvůrcem samotným. Obvykle se jedná o zboží nebo zpeněžené platformy.";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Načítaní streamu...";
 | 
			
		||||
"Loading streams…" = "Načítaní streamu…";
 | 
			
		||||
"Matrix Chat" = "Matrix Chat";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Otevírám %@ stream...";
 | 
			
		||||
"Opening %@ stream…" = "Otevírám %@ stream…";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
"Outro" = "Zakončení";
 | 
			
		||||
@@ -543,7 +543,7 @@
 | 
			
		||||
"Available" = "Dostupné";
 | 
			
		||||
"Are you sure you want to remove %@ from Favorites?" = "Opravdu chcete odstranit %@ z oblíbených položek?";
 | 
			
		||||
"Use system controls with AVPlayer" = "Použití systémových ovládacích prvků s AVPlayerem";
 | 
			
		||||
"Opening file..." = "Otvírání souboru...";
 | 
			
		||||
"Opening file…" = "Otvírání souboru…";
 | 
			
		||||
"No videos to show" = "Žádná videa k zobrazení";
 | 
			
		||||
"Autoplay next" = "Automaticky přehrát další";
 | 
			
		||||
"Inspector" = "Inspektor";
 | 
			
		||||
 
 | 
			
		||||
@@ -182,7 +182,7 @@
 | 
			
		||||
"Large" = "Groß";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Lädt Streams …";
 | 
			
		||||
"Loading streams…" = "Lädt Streams …";
 | 
			
		||||
 | 
			
		||||
/* Video duration filter in search */
 | 
			
		||||
"Long" = "Lang";
 | 
			
		||||
@@ -210,7 +210,7 @@
 | 
			
		||||
"Only when signed in" = "Nur wenn Sie eingeloggt sind";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Öffne %@-stream …";
 | 
			
		||||
"Opening %@ stream…" = "Öffne %@-Stream …";
 | 
			
		||||
"Connection failed" = "Verbindung fehlgeschlagen";
 | 
			
		||||
"Continue from %@" = "Ab %@ fortsetzen";
 | 
			
		||||
"Contributing" = "Beitragen";
 | 
			
		||||
@@ -227,7 +227,7 @@
 | 
			
		||||
"I want to ask a question" = "Ich möchte eine Frage stellen";
 | 
			
		||||
"If you are interested what's coming in future updates, you can track project Milestones." = "Wenn Sie sich für künftige Updates interessieren, können Sie die Meilensteine des Projekts verfolgen.";
 | 
			
		||||
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Das große Layout ist nicht für alle Geräte geeignet und kann dazu führen, dass die Bedienelemente nicht auf den Bildschirm passen.";
 | 
			
		||||
"Opening audio stream..." = "Audiostream wird geöffnet …";
 | 
			
		||||
"Opening audio stream…" = "Audiostream wird geöffnet …";
 | 
			
		||||
"Orientation" = "Ausrichtung";
 | 
			
		||||
"Playlist \"%@\" will be deleted.\nIt cannot be reverted." = "Die Wiedergabeliste \"%@\" wird gelöscht.\nDies kann nicht rückgängig gemacht werden.";
 | 
			
		||||
"Preferred Formats" = "Bevorzugte Formate";
 | 
			
		||||
@@ -569,7 +569,7 @@
 | 
			
		||||
"Enter location address to connect..." = "Geben Sie die Internetadresse ein, um eine Verbindung herzustellen …";
 | 
			
		||||
"Opened File" = "Geöffnete Datei";
 | 
			
		||||
"File Extension" = "Dateierweiterung";
 | 
			
		||||
"Opening file..." = "Datei öffnen …";
 | 
			
		||||
"Opening file…" = "Datei öffnen …";
 | 
			
		||||
"Close video and player on end" = "Video und Player am Ende beenden";
 | 
			
		||||
"Use system controls with AVPlayer" = "Systemsteuerung mit AVPlayer verwenden";
 | 
			
		||||
"Public account" = "Öffentliches Konto";
 | 
			
		||||
 
 | 
			
		||||
@@ -157,7 +157,7 @@
 | 
			
		||||
"LIVE" = "LIVE";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Loading streams...";
 | 
			
		||||
"Loading streams…" = "Loading streams…";
 | 
			
		||||
"Loading..." = "Loading...";
 | 
			
		||||
"Locations" = "Locations";
 | 
			
		||||
"Lock portrait mode" = "Lock portrait mode";
 | 
			
		||||
@@ -202,8 +202,8 @@
 | 
			
		||||
"Open Settings" = "Open Settings";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Opening %@ stream...";
 | 
			
		||||
"Opening audio stream..." = "Opening audio stream...";
 | 
			
		||||
"Opening %@ stream…" = "Opening %@ stream…";
 | 
			
		||||
"Opening audio stream…" = "Opening audio stream…";
 | 
			
		||||
"Orientation" = "Orientation";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -567,7 +567,7 @@
 | 
			
		||||
"Seek" = "Seek";
 | 
			
		||||
"Opened File" = "Opened File";
 | 
			
		||||
"File Extension" = "File Extension";
 | 
			
		||||
"Opening file..." = "Opening file...";
 | 
			
		||||
"Opening file…" = "Opening file…";
 | 
			
		||||
"Public account" = "Public account";
 | 
			
		||||
"Your Accounts" = "Your Accounts";
 | 
			
		||||
"Browse without account" = "Browse without account";
 | 
			
		||||
 
 | 
			
		||||
@@ -138,7 +138,7 @@
 | 
			
		||||
 | 
			
		||||
/* Video date filter in search */
 | 
			
		||||
"Today" = "Hoy";
 | 
			
		||||
"Opening audio stream..." = "Abriendo transmisión de audio...";
 | 
			
		||||
"Opening audio stream…" = "Abriendo transmisión de audio…";
 | 
			
		||||
"Open Video" = "Abrir Video";
 | 
			
		||||
"I want to ask a question" = "Quiero hacer una pregunta";
 | 
			
		||||
"Save history of played videos" = "Guardar historial de videos reproducidos";
 | 
			
		||||
@@ -208,7 +208,7 @@
 | 
			
		||||
"No documents" = "Sin documentos";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Abriendo %@ emisión...";
 | 
			
		||||
"Opening %@ stream…" = "Abriendo %@ emisión…";
 | 
			
		||||
"Documents" = "Documentos";
 | 
			
		||||
"Thumbnails" = "Miniaturas";
 | 
			
		||||
"Password" = "Contraseña";
 | 
			
		||||
@@ -265,7 +265,7 @@
 | 
			
		||||
"Shuffle" = "Mezclar";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Cargando secuencias...";
 | 
			
		||||
"Loading streams…" = "Cargando secuencias…";
 | 
			
		||||
"Public Locations" = "Ubicaciones públicas";
 | 
			
		||||
"Yattee" = "Yattee";
 | 
			
		||||
"No results" = "No hay resultados";
 | 
			
		||||
@@ -558,7 +558,7 @@
 | 
			
		||||
"Available" = "Disponible";
 | 
			
		||||
"Loop one" = "Bucle uno";
 | 
			
		||||
"Use system controls with AVPlayer" = "Utilizar los controles del sistema con AVPlayer";
 | 
			
		||||
"Opening file..." = "Abriendo el archivo...";
 | 
			
		||||
"Opening file…" = "Abriendo el archivo…";
 | 
			
		||||
"No videos to show" = "No hay vídeos que mostrar";
 | 
			
		||||
"Autoplay next" = "Reproducir automáticamente la siguiente";
 | 
			
		||||
"Home Settings" = "Ajustes iniciales";
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,7 @@
 | 
			
		||||
"Profiles" = "نمایهها";
 | 
			
		||||
"New Playlist" = "فهرست پخش جدید";
 | 
			
		||||
"Automatic" = "خودکار";
 | 
			
		||||
"Opening file..." = "در حال باز کردن فایل…";
 | 
			
		||||
"Opening file…" = "در حال باز کردن فایل…";
 | 
			
		||||
"Add Quality Profile" = "افزودن نمایهٔ کیفیت";
 | 
			
		||||
"Close video after playing last in the queue" = "ویدیو را پس از پخش آخرین مورد فهرست ببند";
 | 
			
		||||
 | 
			
		||||
@@ -279,14 +279,14 @@
 | 
			
		||||
"Controls" = "کنترلها";
 | 
			
		||||
"This URL could not be opened" = "این نشانی باز نمیشود";
 | 
			
		||||
"Trending" = "پرطرفدار";
 | 
			
		||||
"Opening audio stream..." = "باز کردن استریم صوتی…";
 | 
			
		||||
"Opening audio stream…" = "باز کردن استریم صوتی…";
 | 
			
		||||
"Statistics" = "آمار";
 | 
			
		||||
"Pause when player is closed" = "پس از بسته شدن پخشکننده مکث کن";
 | 
			
		||||
"Play All" = "همه را پخش کن";
 | 
			
		||||
"Sort: %@" = "ترتیب: %@";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "باز کردن استریم %@…";
 | 
			
		||||
"Opening %@ stream…" = "باز کردن استریم %@…";
 | 
			
		||||
"Next in Queue" = "مورد بعد در صف";
 | 
			
		||||
"Honor orientation lock" = "قفل چرخش صفحه را در نظر بگیر";
 | 
			
		||||
"Rate" = "امتیاز";
 | 
			
		||||
@@ -405,7 +405,7 @@
 | 
			
		||||
"Info" = "اطلاعات";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "درحال دریافت استریم…";
 | 
			
		||||
"Loading streams…" = "درحال دریافت استریم…";
 | 
			
		||||
"No rotation" = "بدون چرخش";
 | 
			
		||||
"Codec" = "کدک (Codec)";
 | 
			
		||||
"Startup section" = "بخش آغازین";
 | 
			
		||||
 
 | 
			
		||||
@@ -282,11 +282,11 @@
 | 
			
		||||
"Large layout is not suitable for all devices and using it may cause controls not to fit on the screen." = "Le grand format n'est pas adapté à tous les appareils et son utilisation peut empêcher certains contrôles de s'afficher à l'écran.";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Chargement des flux...";
 | 
			
		||||
"Loading streams…" = "Chargement des flux…";
 | 
			
		||||
"Lock portrait mode" = "Verrouille l'orientation en mode portrait";
 | 
			
		||||
"Matrix Channel" = "Salon Matrix";
 | 
			
		||||
"Only when signed in" = "Uniquement lorsque vous êtes connecté";
 | 
			
		||||
"Opening audio stream..." = "Ouverture du flux audio…";
 | 
			
		||||
"Opening audio stream…" = "Ouverture du flux audio…";
 | 
			
		||||
"Orientation" = "Orientation";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -424,7 +424,7 @@
 | 
			
		||||
"Milestones" = "Étapes";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Ouverture du flux %@…";
 | 
			
		||||
"Opening %@ stream…" = "Ouverture du flux %@…";
 | 
			
		||||
"Regular size" = "Taille normale";
 | 
			
		||||
"Regular Size" = "Taille normale";
 | 
			
		||||
"Related" = "En relation";
 | 
			
		||||
@@ -567,7 +567,7 @@
 | 
			
		||||
"Seek" = "Recherche";
 | 
			
		||||
"Show scroll to top button in comments" = "Afficher le bouton de retour en haut de la page dans les commentaires";
 | 
			
		||||
"Opened File" = "Fichier ouvert";
 | 
			
		||||
"Opening file..." = "Ouverture du fichier...";
 | 
			
		||||
"Opening file…" = "Ouverture du fichier…";
 | 
			
		||||
"Enter location address to connect..." = "Entrez l'adresse de l'instance pour se connecter...";
 | 
			
		||||
"File Extension" = "Extension de fichier";
 | 
			
		||||
"Public account" = "Compte publique";
 | 
			
		||||
 
 | 
			
		||||
@@ -84,8 +84,8 @@
 | 
			
		||||
"Open Settings" = "सेटिंग खोलें";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "%@ स्ट्रीम खुल रहा…";
 | 
			
		||||
"Opening audio stream..." = "ऑडियो स्ट्रीम खुल रहा…";
 | 
			
		||||
"Opening %@ stream…" = "%@ स्ट्रीम खुल रहा…";
 | 
			
		||||
"Opening audio stream…" = "ऑडियो स्ट्रीम खुल रहा…";
 | 
			
		||||
"If you are reporting a bug, include all relevant details (especially: app version, used device and system version, steps to reproduce)." = "यदि आप किसी बग की रिपोर्ट कर रहे हैं, तो सभी प्रासंगिक विवरण शामिल करें (विशेषकर: ऐप संस्करण, प्रयुक्त डिवाइस और सिस्टम संस्करण, पुन: पेश करने के चरण)।";
 | 
			
		||||
"Increase rate" = "दर बढ़ाएँ";
 | 
			
		||||
"Info" = "जानकारी";
 | 
			
		||||
@@ -104,7 +104,7 @@
 | 
			
		||||
"LIVE" = "लाइव";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "स्ट्रीम लोड हो रहें…";
 | 
			
		||||
"Loading streams…" = "स्ट्रीम लोड हो रहें…";
 | 
			
		||||
"Loading..." = "लोड हो रहा…";
 | 
			
		||||
"Locations" = "स्थान";
 | 
			
		||||
"Lock portrait mode" = "पोर्ट्रेट मोड लॉक करें";
 | 
			
		||||
 
 | 
			
		||||
@@ -158,8 +158,8 @@
 | 
			
		||||
"Open Settings" = "Apri Impostazioni";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Apertura stream %@...";
 | 
			
		||||
"Opening audio stream..." = "Apertura stream audio...";
 | 
			
		||||
"Opening %@ stream…" = "Apertura stream %@…";
 | 
			
		||||
"Opening audio stream…" = "Apertura stream audio…";
 | 
			
		||||
"Orientation" = "Orientamento";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -170,7 +170,7 @@
 | 
			
		||||
"LIVE" = "DIRETTA";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Caricamento stream...";
 | 
			
		||||
"Loading streams…" = "Caricamento stream…";
 | 
			
		||||
"Loading..." = "Caricamento...";
 | 
			
		||||
"Locations" = "Posizioni";
 | 
			
		||||
"Low quality" = "Qualità bassa";
 | 
			
		||||
@@ -569,7 +569,7 @@
 | 
			
		||||
"Enter location address to connect..." = "Inserisci posizione per connetterti...";
 | 
			
		||||
"Opened File" = "File aperto";
 | 
			
		||||
"File Extension" = "Estensione file";
 | 
			
		||||
"Opening file..." = "Apro file...";
 | 
			
		||||
"Opening file…" = "Apro file…";
 | 
			
		||||
"Close video and player on end" = "Chiudi video e riproduttore alla fine";
 | 
			
		||||
"Use system controls with AVPlayer" = "Usa controlli di sistema con AVPlayer";
 | 
			
		||||
"Public account" = "Account pubblico";
 | 
			
		||||
 
 | 
			
		||||
@@ -87,7 +87,7 @@
 | 
			
		||||
"Large" = "大";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "ストリーム読込中...";
 | 
			
		||||
"Loading streams…" = "ストリーム読込中…";
 | 
			
		||||
"Lock portrait mode" = "縦モードをロック";
 | 
			
		||||
"LIVE" = "ライブ";
 | 
			
		||||
"Locations" = "場所";
 | 
			
		||||
@@ -108,10 +108,10 @@
 | 
			
		||||
"Offtopic in Music Videos" = "音楽動画の非音楽部分";
 | 
			
		||||
"Only when signed in" = "ログイン時のみ";
 | 
			
		||||
"Orientation" = "向き";
 | 
			
		||||
"Opening audio stream..." = "音声ストリーム 開始中...";
 | 
			
		||||
"Opening audio stream…" = "音声ストリーム 開始中…";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "%@ ストリーム 開始中...";
 | 
			
		||||
"Opening %@ stream…" = "%@ ストリーム 開始中…";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
"Outro" = "終了シーン";
 | 
			
		||||
@@ -566,7 +566,7 @@
 | 
			
		||||
"Show scroll to top button in comments" = "コメント欄に「上に戻る」表示";
 | 
			
		||||
"Opened File" = "開いたファイル";
 | 
			
		||||
"File Extension" = "ファイル拡張子";
 | 
			
		||||
"Opening file..." = "ファイルを読み込み中...";
 | 
			
		||||
"Opening file…" = "ファイルを読み込み中…";
 | 
			
		||||
"Your Accounts" = "アカウントを使用";
 | 
			
		||||
"Public account" = "公開アカウント";
 | 
			
		||||
"Browse without account" = "アカウントなしで閲覧";
 | 
			
		||||
 
 | 
			
		||||
@@ -82,7 +82,7 @@
 | 
			
		||||
"LIVE" = "Direkte";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Laster inn strømmer …";
 | 
			
		||||
"Loading streams…" = "Laster inn strømmer …";
 | 
			
		||||
 | 
			
		||||
/* Video duration filter in search */
 | 
			
		||||
"Long" = "Lang";
 | 
			
		||||
@@ -223,7 +223,7 @@
 | 
			
		||||
"Rate" = "Takt";
 | 
			
		||||
"Not Playing" = "Spiller ikke";
 | 
			
		||||
"Open \"Playlists\" tab to create new one" = "Åpne «Spillelister»-fanen for å opprette ny";
 | 
			
		||||
"Opening audio stream..." = "Åpner lydstrøm …";
 | 
			
		||||
"Opening audio stream…" = "Åpner lydstrøm …";
 | 
			
		||||
"Password" = "Passord";
 | 
			
		||||
"Nothing" = "Ingenting";
 | 
			
		||||
"Picture in Picture" = "Bilde-i-bilde";
 | 
			
		||||
@@ -303,7 +303,7 @@
 | 
			
		||||
"Offtopic in Music Videos" = "Urelaterte ting i musikkvideoer";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Åpner %@-strøm …";
 | 
			
		||||
"Opening %@ stream…" = "Åpner %@-strøm …";
 | 
			
		||||
"Play in PiP" = "Bilde-i-bilde";
 | 
			
		||||
"Pause when entering background" = "Pause ved forsendelse til bakgrunnen";
 | 
			
		||||
"Promoting a product or service that is directly related to the creator themselves. This usually includes merchandise or promotion of monetized platforms." = "Promoterer noe som har å gjøre med skaperen direkte. Vanligvis effekter eller betalte plattformer.";
 | 
			
		||||
 
 | 
			
		||||
@@ -157,7 +157,7 @@
 | 
			
		||||
"LIVE" = "LIVE";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Ładowanie strumieni...";
 | 
			
		||||
"Loading streams…" = "Ładowanie strumieni…";
 | 
			
		||||
"Loading..." = "Ładowanie…";
 | 
			
		||||
"Locations" = "Lokalizacje";
 | 
			
		||||
"Lock portrait mode" = "Zablokuj tryb portretowy";
 | 
			
		||||
@@ -202,8 +202,8 @@
 | 
			
		||||
"Open Settings" = "Otwórz Ustawienia";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Otwieranie strumienia %@…";
 | 
			
		||||
"Opening audio stream..." = "Otwieranie strumienia audio...";
 | 
			
		||||
"Opening %@ stream…" = "Otwieranie strumienia %@…";
 | 
			
		||||
"Opening audio stream…" = "Otwieranie strumienia audio…";
 | 
			
		||||
"Orientation" = "Orientacja";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -570,7 +570,7 @@
 | 
			
		||||
"Enter location address to connect..." = "Wprowadź adres lokalizacji, aby połączyć...";
 | 
			
		||||
"Opened File" = "Otwarty plik";
 | 
			
		||||
"File Extension" = "Rozszerzenie pliku";
 | 
			
		||||
"Opening file..." = "Otwieranie pliku...";
 | 
			
		||||
"Opening file…" = "Otwieranie pliku…";
 | 
			
		||||
"Public account" = "Konto publiczne";
 | 
			
		||||
"Your Accounts" = "Twoje konta";
 | 
			
		||||
"Browse without account" = "Przeglądanie bez konta";
 | 
			
		||||
 
 | 
			
		||||
@@ -102,7 +102,7 @@
 | 
			
		||||
"Just watched" = "Acabou de assistir";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Carregando streams…";
 | 
			
		||||
"Loading streams…" = "Carregando streams…";
 | 
			
		||||
"Medium quality" = "Qualidade média";
 | 
			
		||||
"No description" = "Sem descrição";
 | 
			
		||||
"No Playlists" = "Sem playlists";
 | 
			
		||||
@@ -114,8 +114,8 @@
 | 
			
		||||
"Open Settings" = "Abrir Ajustes";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Abrindo stream %@…";
 | 
			
		||||
"Opening audio stream..." = "Abrindo stream de áudio…";
 | 
			
		||||
"Opening %@ stream…" = "Abrindo stream %@…";
 | 
			
		||||
"Opening audio stream…" = "Abrindo stream de áudio…";
 | 
			
		||||
"Orientation" = "Orientação";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -569,7 +569,7 @@
 | 
			
		||||
"Enter account credentials to connect..." = "Insira as credenciais da conta para conectar…";
 | 
			
		||||
"Opened File" = "Arquivo Aberto";
 | 
			
		||||
"File Extension" = "Extensão do Arquivo";
 | 
			
		||||
"Opening file..." = "Abrindo arquivo…";
 | 
			
		||||
"Opening file…" = "Abrindo arquivo…";
 | 
			
		||||
"Browse without account" = "Navegar sem uma conta";
 | 
			
		||||
"Rotate when entering fullscreen on landscape video" = "Girar quando entrar no modo tela cheia em vídeo em paisagem";
 | 
			
		||||
"Landscape left" = "Paisagem à esquerda";
 | 
			
		||||
 
 | 
			
		||||
@@ -196,7 +196,7 @@
 | 
			
		||||
"LIVE" = "AO VIVO";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Carregando streams…";
 | 
			
		||||
"Loading streams…" = "Carregando streams…";
 | 
			
		||||
"Loading..." = "Carregando…";
 | 
			
		||||
"Locations" = "Localizações";
 | 
			
		||||
"Lock portrait mode" = "Travar modo retrato";
 | 
			
		||||
@@ -237,8 +237,8 @@
 | 
			
		||||
"Open Settings" = "Abrir Ajustes";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Abrindo stream %@…";
 | 
			
		||||
"Opening audio stream..." = "Abrindo stream de áudio…";
 | 
			
		||||
"Opening %@ stream…" = "Abrindo stream %@…";
 | 
			
		||||
"Opening audio stream…" = "Abrindo stream de áudio…";
 | 
			
		||||
"Orientation" = "Orientação";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -557,7 +557,7 @@
 | 
			
		||||
"Keep channels with unwatched videos on top of subscriptions list" = "Manter canais com vídeos não vistos no topo da lista de inscrições";
 | 
			
		||||
"Opened File" = "Ficheiro Aberto";
 | 
			
		||||
"File Extension" = "Extensão do Ficheiro";
 | 
			
		||||
"Opening file..." = "A abrir ficheiro…";
 | 
			
		||||
"Opening file…" = "A abrir ficheiro…";
 | 
			
		||||
"Close video and player on end" = "Fechar vídeo e player ao final";
 | 
			
		||||
"Use system controls with AVPlayer" = "Usar controles do sistema com o AVPlayer";
 | 
			
		||||
"Public account" = "Conta pública";
 | 
			
		||||
 
 | 
			
		||||
@@ -89,7 +89,7 @@
 | 
			
		||||
"LIVE" = "LIVE";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Se încarcă fluxurile...";
 | 
			
		||||
"Loading streams…" = "Se încarcă fluxurile…";
 | 
			
		||||
"Locations" = "Locații";
 | 
			
		||||
"Mark watched videos with" = "Marcați videoclipurile vizionate cu";
 | 
			
		||||
"Matrix Channel" = "Canal Matrix";
 | 
			
		||||
@@ -100,7 +100,7 @@
 | 
			
		||||
"Nothing" = "Nimic";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Se deschide %@ flux...";
 | 
			
		||||
"Opening %@ stream…" = "Se deschide %@ flux…";
 | 
			
		||||
"Play Last" = "Reda ultimul";
 | 
			
		||||
"Player" = "Player";
 | 
			
		||||
"Playlist" = "Playlist";
 | 
			
		||||
@@ -243,7 +243,7 @@
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
"Outro" = "Outro";
 | 
			
		||||
"Orientation" = "Orientare";
 | 
			
		||||
"Opening audio stream..." = "Se deschide fluxul audio...";
 | 
			
		||||
"Opening audio stream…" = "Se deschide fluxul audio…";
 | 
			
		||||
"Password" = "Parolă";
 | 
			
		||||
"Pause" = "Pauză";
 | 
			
		||||
"Pause when entering background" = "Pauză când intrați în fundal";
 | 
			
		||||
@@ -568,7 +568,7 @@
 | 
			
		||||
"Enter account credentials to connect..." = "Introduceți acreditările contului pentru a vă conecta...";
 | 
			
		||||
"Enter location address to connect..." = "Introdu adresa locației pentru a te conecta...";
 | 
			
		||||
"Opened File" = "Fișier deschis";
 | 
			
		||||
"Opening file..." = "Deschiderea fișierului...";
 | 
			
		||||
"Opening file…" = "Deschiderea fișierului…";
 | 
			
		||||
"File Extension" = "Extensie fișier";
 | 
			
		||||
"Use system controls with AVPlayer" = "Utilizați controalele de sistem cu AVPlayer";
 | 
			
		||||
"Rotate when entering fullscreen on landscape video" = "Rotiți când intrați pe ecran complet în videoclipul peisaj";
 | 
			
		||||
 
 | 
			
		||||
@@ -370,7 +370,7 @@
 | 
			
		||||
"LIVE" = "ПРЯМАЯ ТРАНСЛЯЦИЯ";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Загрузка прямой трансляции...";
 | 
			
		||||
"Loading streams…" = "Загрузка прямой трансляции…";
 | 
			
		||||
"Loading..." = "Загрузка...";
 | 
			
		||||
"Lock portrait mode" = "Блокировка портретного режима";
 | 
			
		||||
"Low" = "Низкое";
 | 
			
		||||
@@ -405,8 +405,8 @@
 | 
			
		||||
"Open Settings" = "Отрыть настройки";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Открытие %@ прямой трансляции...";
 | 
			
		||||
"Opening audio stream..." = "Открытие прямой трансляции аудио...";
 | 
			
		||||
"Opening %@ stream…" = "Открытие %@ прямой трансляции…";
 | 
			
		||||
"Opening audio stream…" = "Открытие прямой трансляции аудио…";
 | 
			
		||||
"Orientation" = "Ориентация";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -591,7 +591,7 @@
 | 
			
		||||
"Actions buttons" = "Кнопки действия";
 | 
			
		||||
"Show sidebar" = "Показать боковую панель";
 | 
			
		||||
"Browse without account" = "Искать без аккаунта";
 | 
			
		||||
"Opening file..." = "Отрытие файла...";
 | 
			
		||||
"Opening file…" = "Отрытие файла…";
 | 
			
		||||
"Public account" = "Публичный аккаунт";
 | 
			
		||||
"Your Accounts" = "Ваши аккаунты";
 | 
			
		||||
"Close video and player on end" = "Закрыть видео и плеер в конце";
 | 
			
		||||
 
 | 
			
		||||
@@ -110,7 +110,7 @@
 | 
			
		||||
"I want to ask a question" = "Bir soru sormak istiyorum";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Akışlar yükleniyor...";
 | 
			
		||||
"Loading streams…" = "Akışlar yükleniyor…";
 | 
			
		||||
"Edit Quality Profile" = "Kalite Profilini Düzenle";
 | 
			
		||||
"Frontend URL" = "Ön uç URL'si";
 | 
			
		||||
"Close player when starting PiP" = "Resim içinde Resim modu başlatılırken oynatıcıyı kapat";
 | 
			
		||||
@@ -227,7 +227,7 @@
 | 
			
		||||
"No description" = "Açıklama yok";
 | 
			
		||||
"Normal" = "Normal";
 | 
			
		||||
"Open \"Playlists\" tab to create new one" = "Yeni bir tane oluşturmak için \"Oynatma Listeleri\" sekmesini açın";
 | 
			
		||||
"Opening audio stream..." = "Ses akışı açılıyor...";
 | 
			
		||||
"Opening audio stream…" = "Ses akışı açılıyor…";
 | 
			
		||||
"Rate" = "Derecelendir";
 | 
			
		||||
"Orientation" = "Yönlendirme";
 | 
			
		||||
"No results" = "Sonuç yok";
 | 
			
		||||
@@ -396,7 +396,7 @@
 | 
			
		||||
"Open" = "Aç";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "%@ akışı açılıyor...";
 | 
			
		||||
"Opening %@ stream…" = "%@ akışı açılıyor…";
 | 
			
		||||
"Clear Queue before opening" = "Açmadan önce Bekleme Listesini temizle";
 | 
			
		||||
"Copy %@ link with time" = "Bağlantıyı %@ zaman ile kopyala";
 | 
			
		||||
"Show Inspector" = "Denetleyiciyi Göster";
 | 
			
		||||
 
 | 
			
		||||
@@ -259,7 +259,7 @@
 | 
			
		||||
"LIVE" = "В ЕФІРІ";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "Завантаження трансляції...";
 | 
			
		||||
"Loading streams…" = "Завантаження трансляції…";
 | 
			
		||||
"Loading..." = "Завантаження...";
 | 
			
		||||
"Locations" = "Локації";
 | 
			
		||||
"Lock portrait mode" = "Заблокувати портретний режим";
 | 
			
		||||
@@ -298,8 +298,8 @@
 | 
			
		||||
"Open Settings" = "Відрити налаштування";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "Запуск трансляції %@...";
 | 
			
		||||
"Opening audio stream..." = "Запуск аудіо трансляції...";
 | 
			
		||||
"Opening %@ stream…" = "Запуск трансляції %@…";
 | 
			
		||||
"Opening audio stream…" = "Запуск аудіо трансляції…";
 | 
			
		||||
"Orientation" = "Орієнтація";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
 
 | 
			
		||||
@@ -152,7 +152,7 @@
 | 
			
		||||
"Loading..." = "加载中...";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "加载流中...";
 | 
			
		||||
"Loading streams…" = "加载流中…";
 | 
			
		||||
"Locations" = "地址";
 | 
			
		||||
"Lock portrait mode" = "锁定竖屏模式";
 | 
			
		||||
 | 
			
		||||
@@ -191,8 +191,8 @@
 | 
			
		||||
"Open Settings" = "打开设置";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "正在打开 %@ 的流...";
 | 
			
		||||
"Opening audio stream..." = "正在打开音频流...";
 | 
			
		||||
"Opening %@ stream…" = "正在打开 %@ 的流…";
 | 
			
		||||
"Opening audio stream…" = "正在打开音频流…";
 | 
			
		||||
"Orientation" = "方向";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
@@ -530,7 +530,7 @@
 | 
			
		||||
"Show scroll to top button in comments" = "在评论中显示“滚动到顶部”按钮";
 | 
			
		||||
"Opened File" = "打开的文件";
 | 
			
		||||
"File Extension" = "文件扩展";
 | 
			
		||||
"Opening file..." = "打开文件中...";
 | 
			
		||||
"Opening file…" = "打开文件中…";
 | 
			
		||||
"Single tap gesture" = "单击手势";
 | 
			
		||||
"Right click channel thumbnail to open context menu with more actions" = "右键单击频道缩略图以打开具有更多操作的上下文菜单";
 | 
			
		||||
"Show unwatched feed badges" = "显示未观看的 Feed 标志";
 | 
			
		||||
 
 | 
			
		||||
@@ -252,7 +252,7 @@
 | 
			
		||||
"Interface" = "介面";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Loading streams..." = "加載中...";
 | 
			
		||||
"Loading streams…" = "加載中…";
 | 
			
		||||
"Loading..." = "加載中...";
 | 
			
		||||
"Locations" = "地址";
 | 
			
		||||
"Lock portrait mode" = "鎖定直屏";
 | 
			
		||||
@@ -292,8 +292,8 @@
 | 
			
		||||
"Open Settings" = "打開設置";
 | 
			
		||||
 | 
			
		||||
/* Loading stream OSD */
 | 
			
		||||
"Opening %@ stream..." = "正在打開 %@ ...";
 | 
			
		||||
"Opening audio stream..." = "正在打開音訊...";
 | 
			
		||||
"Opening %@ stream…" = "正在打開 %@ …";
 | 
			
		||||
"Opening audio stream…" = "正在打開音訊…";
 | 
			
		||||
 | 
			
		||||
/* SponsorBlock category name */
 | 
			
		||||
"Outro" = "結尾";
 | 
			
		||||
@@ -554,7 +554,7 @@
 | 
			
		||||
"Queue - shuffled" = "隊列 - 隨機";
 | 
			
		||||
"Loop one" = "單個循環";
 | 
			
		||||
"File Extension" = "副檔名";
 | 
			
		||||
"Opening file..." = "正在打開文件...";
 | 
			
		||||
"Opening file…" = "正在打開文件…";
 | 
			
		||||
"Public account" = "公共帳戶";
 | 
			
		||||
"Enter account credentials to connect..." = "輸入帳號密碼來連接...";
 | 
			
		||||
"Enter location address to connect..." = "輸入站台地址來連接...";
 | 
			
		||||
 
 | 
			
		||||
@@ -1070,6 +1070,12 @@
 | 
			
		||||
		37FFC440272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
 | 
			
		||||
		37FFC441272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
 | 
			
		||||
		37FFC442272734C3009FFD26 /* Throttle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FFC43F272734C3009FFD26 /* Throttle.swift */; };
 | 
			
		||||
		E25028B02BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
 | 
			
		||||
		E25028B12BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
 | 
			
		||||
		E25028B22BF790F5002CB9FC /* HTTPStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */; };
 | 
			
		||||
		E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
 | 
			
		||||
		E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
 | 
			
		||||
		E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; };
 | 
			
		||||
		FA97174C2A494700001FF53D /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA97174B2A494700001FF53D /* MPVKit */; };
 | 
			
		||||
/* End PBXBuildFile section */
 | 
			
		||||
 | 
			
		||||
@@ -1539,6 +1545,8 @@
 | 
			
		||||
		3DA101AD287C30F50027D920 /* DEVELOPMENT_TEAM.template.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.template.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		3DA101AE287C30F50027D920 /* DEVELOPMENT_TEAM.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DEVELOPMENT_TEAM.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
 | 
			
		||||
		E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = "<group>"; };
 | 
			
		||||
		E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; };
 | 
			
		||||
/* End PBXFileReference section */
 | 
			
		||||
 | 
			
		||||
/* Begin PBXFrameworksBuildPhase section */
 | 
			
		||||
@@ -2286,6 +2294,7 @@
 | 
			
		||||
				372915E52687E3B900F5A35B /* Defaults.swift */,
 | 
			
		||||
				37D2E0D328B67EFC00F64D52 /* Delay.swift */,
 | 
			
		||||
				3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
 | 
			
		||||
				E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */,
 | 
			
		||||
				375B537828DF6CBB004C1D19 /* Localizable.strings */,
 | 
			
		||||
				3729037D2739E47400EA99F6 /* MenuCommands.swift */,
 | 
			
		||||
				37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */,
 | 
			
		||||
@@ -2293,6 +2302,7 @@
 | 
			
		||||
				3700155E271B12DD0049C794 /* SiestaConfiguration.swift */,
 | 
			
		||||
				37FFC43F272734C3009FFD26 /* Throttle.swift */,
 | 
			
		||||
				378FFBC328660172009E3FBE /* URLParser.swift */,
 | 
			
		||||
				E258F3892BF61BD2005B8C28 /* URLTester.swift */,
 | 
			
		||||
				37D4B0C22671614700C925CA /* YatteeApp.swift */,
 | 
			
		||||
				37D4B0C42671614800C925CA /* Assets.xcassets */,
 | 
			
		||||
				37BD07C42698ADEE003EBB87 /* Yattee.entitlements */,
 | 
			
		||||
@@ -3115,6 +3125,7 @@
 | 
			
		||||
				37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */,
 | 
			
		||||
				37F7D82C289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */,
 | 
			
		||||
				375EC95D289EEEE000751258 /* QualityProfile.swift in Sources */,
 | 
			
		||||
				E25028B02BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
 | 
			
		||||
				3773B8102ADC076800B5FEF3 /* ScrollViewMatcher.swift in Sources */,
 | 
			
		||||
				371B7E662759786B00D21217 /* Comment+Fixtures.swift in Sources */,
 | 
			
		||||
				37BE0BD326A1D4780092E2DB /* AppleAVPlayerView.swift in Sources */,
 | 
			
		||||
@@ -3138,6 +3149,7 @@
 | 
			
		||||
				377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
 | 
			
		||||
				3752069D285E910600CA655F /* ChapterView.swift in Sources */,
 | 
			
		||||
				375EC96A289F232600751258 /* QualityProfilesModel.swift in Sources */,
 | 
			
		||||
				E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */,
 | 
			
		||||
				3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */,
 | 
			
		||||
				37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
 | 
			
		||||
				37FFC440272734C3009FFD26 /* Throttle.swift in Sources */,
 | 
			
		||||
@@ -3587,6 +3599,7 @@
 | 
			
		||||
				37D4B19826717E1500C925CA /* Video.swift in Sources */,
 | 
			
		||||
				371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
 | 
			
		||||
				37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
 | 
			
		||||
				E25028B12BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
 | 
			
		||||
				37A7D71C2B680E66009CB1ED /* LocationsSettingsGroupExporter.swift in Sources */,
 | 
			
		||||
				37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
 | 
			
		||||
				374DE88128BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
 | 
			
		||||
@@ -3621,6 +3634,7 @@
 | 
			
		||||
				378E9C39294552A700B2D696 /* ThumbnailView.swift in Sources */,
 | 
			
		||||
				370F4FAB27CC164D001B35DC /* PlayerControlsModel.swift in Sources */,
 | 
			
		||||
				37E8B0ED27B326C00024006F /* TimelineView.swift in Sources */,
 | 
			
		||||
				E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */,
 | 
			
		||||
				370E990B2A1EA8C500D144E9 /* WatchModel.swift in Sources */,
 | 
			
		||||
				3717407E2949D40800FDDBC7 /* ChannelLinkView.swift in Sources */,
 | 
			
		||||
				37FB28422721B22200A57617 /* ContentItem.swift in Sources */,
 | 
			
		||||
@@ -3866,6 +3880,7 @@
 | 
			
		||||
				37130A61277657300033018A /* PersistenceController.swift in Sources */,
 | 
			
		||||
				37E70929271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */,
 | 
			
		||||
				3717407F2949D40800FDDBC7 /* ChannelLinkView.swift in Sources */,
 | 
			
		||||
				E25028B22BF790F5002CB9FC /* HTTPStatus.swift in Sources */,
 | 
			
		||||
				370F4FAA27CC163B001B35DC /* PlayerBackend.swift in Sources */,
 | 
			
		||||
				376A33E62720CB35000C1D6B /* Account.swift in Sources */,
 | 
			
		||||
				3763C98B290C7A50004D3B5F /* OpenVideosView.swift in Sources */,
 | 
			
		||||
@@ -3908,6 +3923,7 @@
 | 
			
		||||
				37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
 | 
			
		||||
				37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */,
 | 
			
		||||
				37FB2860272225E800A57617 /* ContentItemView.swift in Sources */,
 | 
			
		||||
				E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */,
 | 
			
		||||
				37F7D82E289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */,
 | 
			
		||||
				374AB3DD28BCAF7E00DF56FB /* SeekType.swift in Sources */,
 | 
			
		||||
				374C053727242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */,
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user