├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── themes.xml │ │ │ │ └── colors.xml │ │ │ └── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── cameralink │ │ │ │ ├── ui │ │ │ │ └── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Type.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── TailscalePingWorker.kt │ │ │ │ ├── TailscalePingScheduler.kt │ │ │ │ ├── TailscalePingService.kt │ │ │ │ ├── TailscalePinger.kt │ │ │ │ ├── CameraStreamingService.kt │ │ │ │ ├── CameraScreen.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ └── StreamingServer.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── cameralink │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── example │ │ └── cameralink │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── .gitignore ├── .DS_Store ├── gradle └── wrapper │ └── gradle-wrapper.properties ├── local.properties ├── settings.gradle.kts ├── LICENSE ├── gradle.properties ├── test_stream.bat └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onepersonhere/camera-link/HEAD/.DS_Store -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Camera Link 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Nov 01 20:48:20 SGT 2025 2 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 484 | 678 | 679 | 680 |
681 |

📹 CameraLink IP Camera

682 |
683 |
684 | Camera Stream 685 |
686 |
687 |

Stream Active

688 | 📸 Take Snapshot 689 | 🔧 Test Server 690 |
691 | 692 |
693 | 694 |
695 |

🔗 Tailscale Keep-Alive

696 |

Monitor and manage Tailscale peer connections

697 | 698 |
699 | 700 |
701 | 702 |
703 | Click "Ping All Devices" to check connection status 704 |
705 | 706 |

📋 Configured Peers

707 |
708 | Loading peers... 709 |
710 | 711 |

➕ Add Peer

712 |
713 | 719 | 720 |
721 |

722 | 💡 Tip: Use Tailscale MagicDNS hostnames (e.g., "laptop-name") or IP addresses (100.64-127.x.x range) 723 |

724 |
725 | 726 |
727 | Debug Info:
728 |
Testing server connection...
729 | Stream URL: /stream
730 | Snapshot URL: /snapshot
731 | Tailscale API: /api/tailscale/*
732 | Open browser console (F12) for more details 733 |
734 |
735 | 736 | 737 | """.trimIndent() 738 | 739 | return newFixedLengthResponse(Response.Status.OK, "text/html", html) 740 | } 741 | 742 | private fun serveMjpegStream(): Response { 743 | println("StreamingServer: MJPEG stream requested") 744 | 745 | return newChunkedResponse( 746 | Response.Status.OK, 747 | "multipart/x-mixed-replace; boundary=frame", 748 | object : java.io.InputStream() { 749 | private var frameIterator = 0 750 | private var currentData: ByteArrayInputStream? = null 751 | 752 | override fun read(): Int { 753 | while (true) { 754 | if (currentData != null) { 755 | val b = currentData?.read() ?: -1 756 | if (b != -1) return b 757 | currentData = null 758 | } 759 | 760 | val frame = currentFrame.get() 761 | if (frame != null && frame.isNotEmpty()) { 762 | val header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.size}\r\n\r\n" 763 | val footer = "\r\n" 764 | 765 | val combined = header.toByteArray() + frame + footer.toByteArray() 766 | currentData = ByteArrayInputStream(combined) 767 | 768 | frameIterator++ 769 | if (frameIterator % 30 == 0) { 770 | println("StreamingServer: Sent $frameIterator frames") 771 | } 772 | } else { 773 | if (frameIterator == 0) { 774 | println("StreamingServer: WARNING - No frames available yet") 775 | } 776 | Thread.sleep(33) 777 | } 778 | } 779 | } 780 | 781 | override fun available(): Int = 1 782 | } 783 | ) 784 | } 785 | 786 | private fun serveSnapshot(): Response { 787 | val frame = currentFrame.get() 788 | println("StreamingServer: Snapshot requested, frame available: ${frame != null}, size: ${frame?.size ?: 0}") 789 | return if (frame != null && frame.isNotEmpty()) { 790 | newFixedLengthResponse(Response.Status.OK, "image/jpeg", ByteArrayInputStream(frame), frame.size.toLong()) 791 | } else { 792 | newFixedLengthResponse(Response.Status.SERVICE_UNAVAILABLE, MIME_PLAINTEXT, "No frame available") 793 | } 794 | } 795 | } 796 | 797 | --------------------------------------------------------------------------------