// ==UserScript==
// @name Reddit Video Saver for iOS Safari
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Auto scan and download Reddit videos on iOS Safari with touch-friendly UI and fallback download methods
// @author ChatGPT-Pro
// @match https://www.reddit.com/*
// @grant GM_download
// @grant GM_notification
// @grant GM_xmlhttpRequest
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// Helper utilities
const utils = {
sanitizeFilename(name) {
return name.replace(/[^\w\s.-]/g, '_').slice(0, 80);
},
notify(msg) {
if(typeof GM_notification !== 'undefined') {
GM_notification({title: 'Reddit Video Saver', text: msg, timeout: 3000});
} else {
// Simple toast fallback for iOS Safari
const toast = document.createElement("div");
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #ff4500;
color: white;
padding: 12px 16px;
border-radius: 8px;
z-index: 99999;
font-size: 16px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-shadow: 0 5px 15px rgba(0,0,0, 0.3);
`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
},
openLinkToSave(url) {
// Open video URL in new tab - user long press to save media
window.open(url, '_blank');
this.notify('Opened video in new tab - long press the video to save.');
}
};
// Main video extractor and UI manager
const scraper = {
videoData: null, // Store extracted info from current page
// Extract video URL(s) from Reddit JSON API or DOM
async scanForVideo() {
this.videoData = null;
try {
const postId = this.getPostIdFromUrl();
if(!postId) return null;
// Fetch Reddit JSON API data about the post
const apiUrl = `https://www.reddit.com/comments/${postId}.json`;
const response = await fetch(apiUrl);
const json = await response.json();
if(!json || !json[0] || !json[0].data.children.length) return null;
const postData = json[0].data.children[0].data;
// Check if Reddit video is available
if(postData.secure_media?.reddit_video) {
const videoUrl = postData.secure_media.reddit_video.fallback_url;
const title = postData.title || 'reddit_video';
this.videoData = {
url: videoUrl,
title: utils.sanitizeFilename(title)
};
return this.videoData;
}
} catch(e) {
// Fallback: try find video element in DOM
const videoEl = document.querySelector('video');
if(videoEl && (videoEl.src || videoEl.currentSrc)) {
this.videoData = {
url: videoEl.currentSrc || videoEl.src,
title: 'reddit_video'
};
return this.videoData;
}
}
},
getPostIdFromUrl() {
const match = window.location.href.match(/\/comments\/([a-z0-9]+)/i);
return match ? match[1] : null;
},
// Create UI container for the two fixed buttons
createUi() {
let container = document.getElementById('redditsaver-ui');
if(container) return container;
container = document.createElement('div');
container.id = 'redditsaver-ui';
container.style.cssText = `
position: fixed; top: 4px; left: 50%;
transform: translateX(-50%);
background: rgba(255, 69, 0, 0.95);
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 100000;
display: flex;
gap: 12px;
padding: 8px 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-user-select:none;
`;
document.body.appendChild(container);
return container;
},
// Build scan and download buttons with event handlers
buildButtons() {
const container = this.createUi();
container.innerHTML = ''; // clear old buttons
// Scan button: scans current page for video
const scanBtn = document.createElement('button');
scanBtn.textContent = 'Scan Current Page for Media';
this.styleButton(scanBtn);
scanBtn.onclick = async () => {
scanBtn.disabled = true;
scanBtn.textContent = 'Scanning...';
await this.scanForVideo();
if(this.videoData && this.videoData.url) {
utils.notify('Media found on page!');
} else {
utils.notify('No media found on this page.');
}
scanBtn.textContent = 'Scan Current Page for Media';
scanBtn.disabled = false;
};
container.appendChild(scanBtn);
// Save button: saves video currently detected
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save Media on Page';
this.styleButton(saveBtn);
saveBtn.onclick = async () => {
if(!this.videoData || !this.videoData.url) {
utils.notify('No media detected yet! Tap "Scan" first.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
// Try GM_download if available, fallback open tab
if(typeof GM_download === 'function') {
try {
GM_download({
url: this.videoData.url,
name: this.videoData.title + '.mp4',
onerror: () => {
utils.notify('Download failed, opening video in new tab.');
utils.openLinkToSave(this.videoData.url);
},
onload: () => utils.notify('Download started.')
});
} catch(e) {
utils.openLinkToSave(this.videoData.url);
}
} else {
utils.openLinkToSave(this.videoData.url);
}
setTimeout(() => {
saveBtn.textContent = 'Save Media on Page';
saveBtn.disabled = false;
}, 1500);
};
container.appendChild(saveBtn);
},
styleButton(btn) {
btn.style.cssText = `
background-color: white;
color: rgb(255, 69, 0);
font-weight: 700;
font-size: 14px;
padding: 8px 12px;
border-radius: 8px;
border: none;
cursor: pointer;
user-select:none;
min-width: 120px;
box-shadow: 0 2px 8px rgba(255,69,0,0.4);
transition: background-color 0.3s ease;
`;
btn.addEventListener('touchstart', () => btn.style.backgroundColor = 'rgba(255,69,0,0.1)');
btn.addEventListener('touchend', () => btn.style.backgroundColor = 'white');
btn.addEventListener('mouseenter', () => btn.style.backgroundColor = 'rgba(255,69,0,0.15)');
btn.addEventListener('mouseleave', () => btn.style.backgroundColor = 'white');
},
// Set periodic rescans for SPA navigation or content changes
setRescanOnNavigation() {
let lastUrl = location.href;
new MutationObserver(() => {
if(location.href !== lastUrl) {
lastUrl = location.href;
this.scanForVideo();
}
}).observe(document.body, {childList: true, subtree: true});
},
init() {
this.buildButtons();
this.scanForVideo();
this.setRescanOnNavigation();
}
};
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => scraper.init());
} else {
scraper.init();
}
})();